aboutsummaryrefslogtreecommitdiffstats
path: root/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'core/src')
-rw-r--r--core/src/OC/dialogs.js8
-rw-r--r--core/src/OC/eventsource.js4
-rw-r--r--core/src/OC/index.js4
-rw-r--r--core/src/OC/requesttoken.js39
-rw-r--r--core/src/OC/requesttoken.ts49
-rw-r--r--core/src/components/AccountMenu/AccountMenuEntry.vue42
-rw-r--r--core/src/components/AccountMenu/AccountMenuProfileEntry.vue4
-rw-r--r--core/src/components/AppMenu.vue4
-rw-r--r--core/src/components/AppMenuIcon.vue18
-rw-r--r--core/src/components/ContactsMenu/Contact.vue14
-rw-r--r--core/src/components/LegacyDialogPrompt.vue6
-rw-r--r--core/src/components/Profile/PrimaryActionButton.vue4
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuEntry.vue8
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue8
-rw-r--r--core/src/components/UnifiedSearch/CustomDateRangeModal.vue6
-rw-r--r--core/src/components/UnifiedSearch/LegacySearchResult.vue2
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue111
-rw-r--r--core/src/components/UnifiedSearch/SearchableList.vue24
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue22
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchModal.vue145
-rw-r--r--core/src/components/login/LoginButton.vue2
-rw-r--r--core/src/components/login/LoginForm.vue16
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue96
-rw-r--r--core/src/components/login/ResetPassword.vue149
-rw-r--r--core/src/components/setup/RecommendedApps.vue15
-rw-r--r--core/src/globals.js2
-rw-r--r--core/src/init.js2
-rw-r--r--core/src/install.js156
-rw-r--r--core/src/install.ts43
-rw-r--r--core/src/jquery/requesttoken.js4
-rw-r--r--core/src/public-page-user-menu.ts15
-rw-r--r--core/src/services/WebAuthnAuthenticationService.ts4
-rw-r--r--core/src/session-heartbeat.js168
-rw-r--r--core/src/session-heartbeat.ts158
-rw-r--r--core/src/store/unified-search-external-filters.js8
-rw-r--r--core/src/tests/OC/requesttoken.spec.js44
-rw-r--r--core/src/tests/OC/requesttoken.spec.ts147
-rw-r--r--core/src/tests/OC/session-heartbeat.spec.ts123
-rw-r--r--core/src/twofactor-request-token.ts25
-rw-r--r--core/src/unified-search.ts5
-rw-r--r--core/src/utils/xhr-request.js22
-rw-r--r--core/src/views/AccountMenu.vue50
-rw-r--r--core/src/views/ContactsMenu.vue37
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue12
-rw-r--r--core/src/views/Login.vue102
-rw-r--r--core/src/views/PublicPageMenu.vue8
-rw-r--r--core/src/views/PublicPageUserMenu.vue138
-rw-r--r--core/src/views/Setup.cy.ts369
-rw-r--r--core/src/views/Setup.vue460
-rw-r--r--core/src/views/UnifiedSearch.vue61
-rw-r--r--core/src/views/UnsupportedBrowser.vue4
51 files changed, 2044 insertions, 923 deletions
diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js
index c10f676701d..5c6934e67a2 100644
--- a/core/src/OC/dialogs.js
+++ b/core/src/OC/dialogs.js
@@ -9,7 +9,7 @@ import _ from 'underscore'
import $ from 'jquery'
import IconMove from '@mdi/svg/svg/folder-move.svg?raw'
-import IconCopy from '@mdi/svg/svg/folder-multiple.svg?raw'
+import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
import OC from './index.js'
import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs'
@@ -278,13 +278,13 @@ const Dialogs = {
} else {
builder.setButtonFactory((nodes, path) => {
const buttons = []
- const node = nodes?.[0]?.attributes?.displayName || nodes?.[0]?.basename
- const target = node || basename(path)
+ const [node] = nodes
+ const target = node?.displayname || node?.basename || basename(path)
if (type === FilePickerType.Choose) {
buttons.push({
callback: legacyCallback(callback, FilePickerType.Choose),
- label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: node }) : t('core', 'Choose'),
+ label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'),
type: 'primary',
})
}
diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js
index bdafa364beb..090c351c057 100644
--- a/core/src/OC/eventsource.js
+++ b/core/src/OC/eventsource.js
@@ -7,7 +7,7 @@
/* eslint-disable */
import $ from 'jquery'
-import { getToken } from './requesttoken.js'
+import { getRequestToken } from './requesttoken.ts'
/**
* Create a new event source
@@ -28,7 +28,7 @@ const OCEventSource = function(src, data) {
dataStr += name + '=' + encodeURIComponent(data[name]) + '&'
}
}
- dataStr += 'requesttoken=' + encodeURIComponent(getToken())
+ dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken())
if (!this.useFallBack && typeof EventSource !== 'undefined') {
joinChar = '&'
if (src.indexOf('?') === -1) {
diff --git a/core/src/OC/index.js b/core/src/OC/index.js
index eff3289308a..5afc941b396 100644
--- a/core/src/OC/index.js
+++ b/core/src/OC/index.js
@@ -49,9 +49,7 @@ import {
getPort,
getProtocol,
} from './host.js'
-import {
- getToken as getRequestToken,
-} from './requesttoken.js'
+import { getRequestToken } from './requesttoken.ts'
import {
hideMenus,
registerMenu,
diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js
deleted file mode 100644
index ed89af59c17..00000000000
--- a/core/src/OC/requesttoken.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { emit } from '@nextcloud/event-bus'
-
-/**
- * @private
- * @param {Document} global the document to read the initial value from
- * @param {Function} emit the function to invoke for every new token
- * @return {object}
- */
-export const manageToken = (global, emit) => {
- let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken')
-
- return {
- getToken: () => token,
- setToken: newToken => {
- token = newToken
-
- emit('csrf-token-update', {
- token,
- })
- },
- }
-}
-
-const manageFromDocument = manageToken(document, emit)
-
-/**
- * @return {string}
- */
-export const getToken = manageFromDocument.getToken
-
-/**
- * @param {string} newToken new token
- */
-export const setToken = manageFromDocument.setToken
diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts
new file mode 100644
index 00000000000..8ecf0b3de7e
--- /dev/null
+++ b/core/src/OC/requesttoken.ts
@@ -0,0 +1,49 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { generateUrl } from '@nextcloud/router'
+
+/**
+ * Get the current CSRF token.
+ */
+export function getRequestToken(): string {
+ return document.head.dataset.requesttoken!
+}
+
+/**
+ * Set a new CSRF token (e.g. because of session refresh).
+ * This also emits an event bus event for the updated token.
+ *
+ * @param token - The new token
+ * @fires Error - If the passed token is not a potential valid token
+ */
+export function setRequestToken(token: string): void {
+ if (!token || typeof token !== 'string') {
+ throw new Error('Invalid CSRF token given', { cause: { token } })
+ }
+
+ document.head.dataset.requesttoken = token
+ emit('csrf-token-update', { token })
+}
+
+/**
+ * Fetch the request token from the API.
+ * This does also set it on the current context, see `setRequestToken`.
+ *
+ * @fires Error - If the request failed
+ */
+export async function fetchRequestToken(): Promise<string> {
+ const url = generateUrl('/csrftoken')
+
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error('Could not fetch CSRF token from API', { cause: response })
+ }
+
+ const { token } = await response.json()
+ setRequestToken(token)
+ return token
+}
diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue
index c0cff323c12..d983226d273 100644
--- a/core/src/components/AccountMenu/AccountMenuEntry.vue
+++ b/core/src/components/AccountMenu/AccountMenuEntry.vue
@@ -11,28 +11,30 @@
compact
:href="href"
:name="name"
- target="_self">
+ target="_self"
+ @click="onClick">
<template #icon>
- <img class="account-menu-entry__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>
- <template v-if="loading" #indicator>
- <NcLoadingIcon />
- </template>
</NcListItem>
</template>
-<script>
+<script lang="ts">
import { loadState } from '@nextcloud/initial-state'
+import { defineComponent } from 'vue'
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
const versionHash = loadState('core', 'versionHash', '')
-export default {
+export default defineComponent({
name: 'AccountMenuEntry',
components: {
@@ -55,11 +57,11 @@ export default {
},
active: {
type: Boolean,
- required: true,
+ default: false,
},
icon: {
type: String,
- required: true,
+ default: '',
},
},
@@ -76,11 +78,17 @@ export default {
},
methods: {
- handleClick() {
- this.loading = true
+ 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>
@@ -96,6 +104,12 @@ export default {
}
}
+ &__loading {
+ height: 20px;
+ width: 20px;
+ margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size
+ }
+
:deep(.list-item-content__main) {
width: fit-content;
}
diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
index 853c22986ce..8b895b8ca31 100644
--- a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
+++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
@@ -26,8 +26,8 @@ import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { defineComponent } from 'vue'
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false })
diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue
index 265191768af..88f626ff569 100644
--- a/core/src/components/AppMenu.vue
+++ b/core/src/components/AppMenu.vue
@@ -36,8 +36,8 @@ import { useElementSize } from '@vueuse/core'
import { defineComponent, ref } from 'vue'
import AppMenuEntry from './AppMenuEntry.vue'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
import logger from '../logger'
export default defineComponent({
diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue
index f2cee75e644..1b0d48daf8c 100644
--- a/core/src/components/AppMenuIcon.vue
+++ b/core/src/components/AppMenuIcon.vue
@@ -14,24 +14,25 @@
</template>
<script setup lang="ts">
-import type { INavigationEntry } from '../types/navigation'
+import type { INavigationEntry } from '../types/navigation.ts'
+
import { n } from '@nextcloud/l10n'
import { computed } from 'vue'
-
-import IconDot from 'vue-material-design-icons/Circle.vue'
+import IconDot from 'vue-material-design-icons/CircleOutline.vue'
const props = defineProps<{
app: INavigationEntry
}>()
-const ariaHidden = computed(() => String(props.app.unread > 0))
+// only hide if there are no unread notifications
+const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined)
const ariaLabel = computed(() => {
- if (ariaHidden.value === 'true') {
- return ''
+ if (!props.app.unread) {
+ return undefined
}
- return props.app.name
- + (props.app.unread > 0 ? ` (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` : '')
+
+ return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})`
})
</script>
@@ -51,6 +52,7 @@ $unread-indicator-size: 10px;
height: $icon-size;
width: $icon-size;
filter: var(--background-image-invert-if-bright);
+ mask: var(--header-menu-icon-mask);
}
&__unread {
diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue
index ec74697341c..322f53647b1 100644
--- a/core/src/components/ContactsMenu/Contact.vue
+++ b/core/src/components/ContactsMenu/Contact.vue
@@ -54,13 +54,13 @@
</template>
<script>
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import { getEnabledContactsMenuActions } from '@nextcloud/vue/dist/Functions/contactsMenu.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',
diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue
index 5fb21926e4d..f2ee4be9151 100644
--- a/core/src/components/LegacyDialogPrompt.vue
+++ b/core/src/components/LegacyDialogPrompt.vue
@@ -28,9 +28,9 @@
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
+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',
diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue
index 8ec77e88ea2..dbc446b3d90 100644
--- a/core/src/components/Profile/PrimaryActionButton.vue
+++ b/core/src/components/Profile/PrimaryActionButton.vue
@@ -21,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/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
index a5a1913ac2b..413806c7089 100644
--- a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
+++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
@@ -11,22 +11,24 @@
role="presentation"
@click="$emit('click')">
<template #icon>
- <div role="presentation" :class="['icon', icon, 'public-page-menu-entry__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 NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
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
+ icon?: string
href: string
details?: string
}>()
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
index 992ea631600..0f02bdf7524 100644
--- a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
@@ -32,10 +32,10 @@ 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/dist/Components/NcButton.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+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<{
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
index 332a4286863..d86192d156e 100644
--- a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
+++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
@@ -37,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 085a6936f2b..4592adf08c9 100644
--- a/core/src/components/UnifiedSearch/LegacySearchResult.vue
+++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue
@@ -42,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',
diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue
index 231ac97642c..4f33fbd54cc 100644
--- a/core/src/components/UnifiedSearch/SearchResult.vue
+++ b/core/src/components/UnifiedSearch/SearchResult.vue
@@ -3,18 +3,18 @@
- 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="{
@@ -32,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',
@@ -101,72 +101,59 @@ export default {
</script>
<style lang="scss" scoped>
-@use "sass:math";
-$clickable-area: 44px;
-$margin: 10px;
-
-.result-items {
- &__item:deep {
-
- a {
- 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);
- }
+.result-item {
+ :deep(a) {
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
- * {
- cursor: pointer;
- }
+ * {
+ cursor: pointer;
+ }
+ }
+ &__icon {
+ 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);
}
- &-icon {
- overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
- border-radius: var(--border-radius);
- background-repeat: no-repeat;
- background-position: center center;
+ &--no-preview {
background-size: 32px;
+ }
- &--rounded {
- border-radius: math.div($clickable-area, 2);
- }
-
- &--no-preview {
- background-size: 32px;
- }
-
- &--with-thumbnail {
- background-size: cover;
- }
+ &--with-thumbnail {
+ background-size: cover;
+ }
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
- border: 1px solid var(--color-border);
- }
+ &--with-thumbnail:not(#{&}--rounded) {
+ border: 1px solid var(--color-border);
+ // compensate for border
+ max-height: calc(var(--default-clickable-area) - 2px);
+ max-width: calc(var(--default-clickable-area) - 2px);
+ }
- img {
- // Make sure to keep ratio
- width: 100%;
- height: 100%;
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
- object-fit: cover;
- object-position: center;
- }
+ object-fit: cover;
+ object-position: center;
}
-
}
}
</style>
diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
index b2081c2c5ee..d7abb6ffdbb 100644
--- a/core/src/components/UnifiedSearch/SearchableList.vue
+++ b/core/src/components/UnifiedSearch/SearchableList.vue
@@ -17,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"
@@ -42,7 +42,7 @@
<div v-else class="searchable-list__empty-content">
<NcEmptyContent :name="emptyContentText">
<template #icon>
- <AlertCircleOutline />
+ <IconAlertCircleOutline />
</template>
</NcEmptyContent>
</div>
@@ -51,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
index 67853490d9f..171eada8a06 100644
--- a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
+++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
@@ -32,7 +32,7 @@
{{ t('core', 'Search everywhere') }}
</template>
<template #icon>
- <NcIconSvgWrapper :path="mdiCloudSearch" />
+ <NcIconSvgWrapper :path="mdiCloudSearchOutline" />
</template>
</NcButton>
</div>
@@ -41,15 +41,15 @@
<script lang="ts" setup>
import type { ComponentPublicInstance } from 'vue'
-import { mdiCloudSearch, mdiClose } from '@mdi/js'
+import { mdiCloudSearchOutline, mdiClose } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
-import { useIsMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
+import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { useElementSize } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import { useElementSize } from '@vueuse/core'
+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,
@@ -123,7 +123,7 @@ function clearAndCloseSearch() {
// this can break at any time the component library changes
:deep(input) {
// search global width + close button width
- padding-inline-end: calc(v-bind('searchGlobalButtonWidth') + var(--default-clickable-area));
+ padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area));
}
}
}
@@ -132,8 +132,8 @@ function clearAndCloseSearch() {
transition: width var(--animation-quick) linear;
}
-// Make the position absolut during the transition
-// this is needed to "hide" the button begind it
+// Make the position absolute during the transition
+// this is needed to "hide" the button behind it
.v-leave-active {
position: absolute !important;
}
@@ -141,7 +141,7 @@ function clearAndCloseSearch() {
.v-enter,
.v-leave-to {
&.local-unified-search {
- // Start with only the overlayed button
+ // Start with only the overlay button
--local-search-width: var(--clickable-area-large);
}
}
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
index 7400956f96b..b21c65301c4 100644
--- a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
+++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
@@ -121,7 +121,7 @@
</h3>
<div v-for="providerResult in results" :key="providerResult.id" class="result">
<h4 :id="`unified-search-result-${providerResult.id}`" class="result-title">
- {{ providerResult.provider }}
+ {{ providerResult.name }}
</h4>
<ul class="result-items" :aria-labelledby="`unified-search-result-${providerResult.id}`">
<SearchResult v-for="(result, index) in providerResult.results"
@@ -129,14 +129,14 @@
v-bind="result" />
</ul>
<div class="result-footer">
- <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
+ <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.provider }}
+ {{ t('core', 'Search in') }} {{ providerResult.name }}
<template #icon>
<IconArrowRight :size="20" />
</template>
@@ -159,19 +159,19 @@ import debounce from 'debounce'
import { unifiedSearchLogger } from '../../logger'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
-import IconAccountGroup from 'vue-material-design-icons/AccountGroup.vue'
-import IconCalendarRange from 'vue-material-design-icons/CalendarRange.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/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
import CustomDateRangeModal from './CustomDateRangeModal.vue'
import FilterChip from './SearchFilterChip.vue'
@@ -252,11 +252,10 @@ export default defineComponent({
providerResultLimit: 5,
dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
personFilter: { id: 'person', type: 'person', name: '' },
- dateFilterIsApplied: false,
- personFilterIsApplied: false,
filteredProviders: [],
searching: false,
searchQuery: '',
+ lastSearchQuery: '',
placessearchTerm: '',
dateTimeFilter: null,
filters: [],
@@ -264,6 +263,7 @@ export default defineComponent({
contacts: [],
showDateRangeModal: false,
internalIsVisible: this.open,
+ initialized: false,
}
},
@@ -308,6 +308,18 @@ export default defineComponent({
// 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)
}
@@ -317,25 +329,19 @@ export default defineComponent({
query: {
immediate: true,
handler() {
- this.searchQuery = this.query.trim()
+ this.searchQuery = this.query
+ },
+ },
+
+ searchQuery: {
+ handler() {
+ this.$emit('update:query', this.searchQuery)
},
},
},
mounted() {
subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
- getProviders().then((providers) => {
- this.providers = providers
- this.externalFilters.forEach(filter => {
- this.providers.push(filter)
- })
- this.providers = this.groupProvidersByApp(this.providers)
- unifiedSearchLogger.debug('Search providers', { providers: this.providers })
- })
- getContacts({ searchTerm: '' }).then((contacts) => {
- this.contacts = this.mapContacts(contacts)
- unifiedSearchLogger.debug('Contacts', { contacts: this.contacts })
- })
},
methods: {
/**
@@ -361,19 +367,25 @@ export default defineComponent({
this.$refs.searchInput?.focus()
})
},
- find(query: string) {
+ 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 = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
- const searchProvider = (provider, filters) => {
+ const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers)
+ const searchProvider = (provider) => {
const params = {
- type: provider.id,
+ type: provider.searchFrom ?? provider.id,
query,
cursor: null,
extraQueries: provider.extraParams,
@@ -381,31 +393,38 @@ export default defineComponent({
// This block of filter checks should be dynamic somehow and should be handled in
// nextcloud/search lib
- if (filters.dateFilterIsApplied) {
- if (provider.filters?.since && provider.filters?.until) {
- params.since = this.dateFilter.startFrom
- params.until = this.dateFilter.endAt
- }
- }
+ const activeFilters = this.filters.filter(filter => {
+ return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
+ })
- if (filters.personFilterIsApplied) {
- if (provider.filters?.person) {
- params.person = this.personFilter.user
+ activeFilters.forEach(filter => {
+ switch (filter.type) {
+ case 'date':
+ if (provider.filters?.since && provider.filters?.until) {
+ params.since = this.dateFilter.startFrom
+ params.until = this.dateFilter.endAt
+ }
+ break
+ case 'person':
+ if (provider.filters?.person) {
+ params.person = this.personFilter.user
+ }
+ break
}
- }
+ })
if (this.providerResultLimit > 5) {
params.limit = this.providerResultLimit
+ unifiedSearchLogger.debug('Limiting search to', params.limit)
}
const request = unifiedSearch(params).request
request().then((response) => {
newResults.push({
- id: provider.id,
- provider: provider.name,
- inAppSearch: provider.inAppSearch,
+ ...provider,
results: response.data.ocs.data.entries,
+ limit: params.limit ?? 5,
})
unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })
@@ -414,12 +433,8 @@ export default defineComponent({
this.searching = false
})
}
- providersToSearch.forEach(provider => {
- const dateFilterIsApplied = this.dateFilterIsApplied
- const personFilterIsApplied = this.personFilterIsApplied
- searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
- })
+ providersToSearch.forEach(searchProvider)
},
updateResults(newResults) {
let updatedResults = [...this.results]
@@ -477,7 +492,7 @@ export default defineComponent({
})
},
applyPersonFilter(person) {
- this.personFilterIsApplied = true
+
const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
if (existingPersonFilter === -1) {
this.personFilter.id = person.id
@@ -497,16 +512,20 @@ export default defineComponent({
this.debouncedFind(this.searchQuery)
unifiedSearchLogger.debug('Person filter applied', { person })
},
- loadMoreResultsForProvider(providerId) {
+ async loadMoreResultsForProvider(provider) {
this.providerResultLimit += 5
- this.filters = this.filters.filter(filter => filter.type !== 'provider')
- const provider = this.providers.find(provider => provider.id === providerId)
- this.addProviderFilter(provider, true)
+ this.find(this.searchQuery, [provider])
},
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
+ unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
if (!providerFilter.id) return
if (providerFilter.isPluginFilter) {
- providerFilter.callback()
+ // 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
@@ -519,11 +538,8 @@ export default defineComponent({
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
}
this.filteredProviders.push({
- id: providerFilter.id,
- name: providerFilter.name,
- icon: providerFilter.icon,
+ ...providerFilter,
type: providerFilter.type || 'provider',
- filters: providerFilter.filters,
isPluginFilter: providerFilter.isPluginFilter || false,
})
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
@@ -542,14 +558,10 @@ export default defineComponent({
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++) {
- // Remove date and person filter
- if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
- this.dateFilterIsApplied = false
+ if (this.filters[i].id === filter.id) {
this.filters.splice(i, 1)
- if (filter.type === 'person') {
- this.personFilterIsApplied = false
- }
this.enableAllProviders()
break
}
@@ -588,7 +600,7 @@ export default defineComponent({
} else {
this.filters.push(this.dateFilter)
}
- this.dateFilterIsApplied = true
+
this.providers.forEach(async (provider, index) => {
this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until']))
})
@@ -648,6 +660,7 @@ export default defineComponent({
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) {
diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue
index fcfdb4d01d9..da387df0ff6 100644
--- a/core/src/components/login/LoginButton.vue
+++ b/core/src/components/login/LoginButton.vue
@@ -20,7 +20,7 @@
<script>
import { translate as t } from '@nextcloud/l10n'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+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.vue b/core/src/components/login/LoginForm.vue
index d031f14140a..8cbe55f1f68 100644
--- a/core/src/components/login/LoginForm.vue
+++ b/core/src/components/login/LoginForm.vue
@@ -17,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"
@@ -103,9 +103,9 @@ 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'
@@ -292,6 +292,7 @@ export default {
.login-form {
text-align: start;
font-size: 1rem;
+ margin: 0;
&__fieldset {
width: 100%;
@@ -304,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 04db5cef05a..bc4d25bf70f 100644
--- a/core/src/components/login/PasswordLessLoginForm.vue
+++ b/core/src/components/login/PasswordLessLoginForm.vue
@@ -5,59 +5,70 @@
<template>
<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', '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" />
+ <h2 id="password-less-login-form-title">
+ {{ t('core', 'Log in with a device') }}
+ </h2>
- <LoginButton v-if="validCredentials"
- :loading="loading"
- @click="authenticate" />
- </fieldset>
+ <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="!supportsWebauthn" 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.ts'
+
+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 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'
import logger from '../../logger'
-export default {
+export default defineComponent({
name: 'PasswordLessLoginForm',
components: {
LoginButton,
InformationIcon,
LockOpenIcon,
+ NcEmptyContent,
NcTextField,
},
props: {
@@ -138,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 254ad4d8e16..fee1deacc36 100644
--- a/core/src/components/login/ResetPassword.vue
+++ b/core/src/components/login/ResetPassword.vue
@@ -4,59 +4,65 @@
-->
<template>
- <form class="login-form" @submit.prevent="submit">
- <fieldset class="login-form__fieldset">
- <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 :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 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>
-
- <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 LoginButton from './LoginButton.vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+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 logger from '../../logger.js'
-export default {
+export default defineComponent({
name: 'ResetPassword',
components: {
LoginButton,
+ NcButton,
NcNoteCard,
NcTextField,
},
+
mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -67,11 +73,12 @@ export default {
required: true,
},
},
+
data() {
return {
error: false,
loading: false,
- message: undefined,
+ message: '',
user: this.username,
}
},
@@ -84,56 +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: start;
- font-size: 1rem;
-
- &__fieldset {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: .5rem;
- }
-
- &__link {
- display: block;
- font-weight: normal !important;
- 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/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
index d6968bb53e4..f2120c28402 100644
--- a/core/src/components/setup/RecommendedApps.vue
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -4,7 +4,7 @@
-->
<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 …') }}
@@ -38,15 +38,16 @@
<div class="dialog-row">
<NcButton v-if="showInstallButton && !installingApps"
- type="tertiary"
- role="link"
- :href="defaultPageUrl">
+ 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">
{{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }}
</NcButton>
@@ -62,8 +63,8 @@ import axios from '@nextcloud/axios'
import pLimit from 'p-limit'
import logger from '../../logger.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const recommended = {
calendar: {
diff --git a/core/src/globals.js b/core/src/globals.js
index 8511b699563..4b07cc17c3e 100644
--- a/core/src/globals.js
+++ b/core/src/globals.js
@@ -29,7 +29,7 @@ import 'strengthify/strengthify.css'
import OC from './OC/index.js'
import OCP from './OCP/index.js'
import OCA from './OCA/index.js'
-import { getToken as getRequestToken } from './OC/requesttoken.js'
+import { getRequestToken } from './OC/requesttoken.ts'
const warnIfNotTesting = function() {
if (window.TESTING === undefined) {
diff --git a/core/src/init.js b/core/src/init.js
index 9e10a6941e1..1bcd8218702 100644
--- a/core/src/init.js
+++ b/core/src/init.js
@@ -8,8 +8,8 @@ import _ from 'underscore'
import $ from 'jquery'
import moment from 'moment'
-import { initSessionHeartBeat } from './session-heartbeat.js'
import OC from './OC/index.js'
+import { initSessionHeartBeat } from './session-heartbeat.ts'
import { setUp as setUpContactsMenu } from './components/ContactsMenu.js'
import { setUp as setUpMainMenu } from './components/MainMenu.js'
import { setUp as setUpUserMenu } from './components/UserMenu.js'
diff --git a/core/src/install.js b/core/src/install.js
deleted file mode 100644
index ea2e2996a2a..00000000000
--- a/core/src/install.js
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import $ from 'jquery'
-import { translate as t } from '@nextcloud/l10n'
-import { linkTo } from '@nextcloud/router'
-
-import { getToken } from './OC/requesttoken.js'
-import getURLParameter from './Util/get-url-parameter.js'
-
-import './jquery/showpassword.js'
-
-import 'jquery-ui/ui/widgets/button.js'
-import 'jquery-ui/themes/base/theme.css'
-import 'jquery-ui/themes/base/button.css'
-
-import 'strengthify'
-import 'strengthify/strengthify.css'
-
-window.addEventListener('DOMContentLoaded', function() {
- const dbtypes = {
- sqlite: !!$('#hasSQLite').val(),
- mysql: !!$('#hasMySQL').val(),
- postgresql: !!$('#hasPostgreSQL').val(),
- oracle: !!$('#hasOracle').val(),
- }
-
- $('#selectDbType').buttonset()
- // change links inside an info box back to their default appearance
- $('#selectDbType p.info a').button('destroy')
-
- if ($('#hasSQLite').val()) {
- $('#use_other_db').hide()
- $('#use_oracle_db').hide()
- } else {
- $('#sqliteInformation').hide()
- }
- $('#adminlogin').change(function() {
- $('#adminlogin').val($.trim($('#adminlogin').val()))
- })
- $('#sqlite').click(function() {
- $('#use_other_db').slideUp(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').show()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#mysql,#pgsql').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#oci').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').show(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-.]+')
- })
-
- $('#showAdvanced').click(function(e) {
- e.preventDefault()
- $('#datadirContent').slideToggle(250)
- $('#databaseBackend').slideToggle(250)
- $('#databaseField').slideToggle(250)
- })
- $('form').submit(function() {
- // Save form parameters
- const post = $(this).serializeArray()
-
- // Show spinner while finishing setup
- $('.float-spinner').show(250)
-
- // Disable inputs
- $('input[type="submit"]').attr('disabled', 'disabled').val($('input[type="submit"]').data('finishing'))
- $('input', this).addClass('ui-state-disabled').attr('disabled', 'disabled')
- // only disable buttons if they are present
- if ($('#selectDbType').find('.ui-button').length > 0) {
- $('#selectDbType').buttonset('disable')
- }
- $('.strengthify-wrapper, .tipsy')
- .css('filter', 'alpha(opacity=30)')
- .css('opacity', 0.3)
-
- // Create the form
- const form = $('<form>')
- form.attr('action', $(this).attr('action'))
- form.attr('method', 'POST')
-
- for (let i = 0; i < post.length; i++) {
- const input = $('<input type="hidden">')
- input.attr(post[i])
- form.append(input)
- }
-
- // Add redirect_url
- const redirectURL = getURLParameter('redirect_url')
- if (redirectURL) {
- const redirectURLInput = $('<input type="hidden">')
- redirectURLInput.attr({
- name: 'redirect_url',
- value: redirectURL,
- })
- form.append(redirectURLInput)
- }
-
- // Submit the form
- form.appendTo(document.body)
- form.submit()
- return false
- })
-
- // Expand latest db settings if page was reloaded on error
- const currentDbType = $('input[type="radio"]:checked').val()
-
- if (currentDbType === undefined) {
- $('input[type="radio"]').first().click()
- }
-
- if (
- currentDbType === 'sqlite'
- || (dbtypes.sqlite && currentDbType === undefined)
- ) {
- $('#datadirContent').hide(250)
- $('#databaseBackend').hide(250)
- $('#databaseField').hide(250)
- $('.float-spinner').hide(250)
- }
-
- $('#adminpass').strengthify({
- zxcvbn: linkTo('core', 'vendor/zxcvbn/dist/zxcvbn.js'),
- titles: [
- t('core', 'Very weak password'),
- t('core', 'Weak password'),
- t('core', 'So-so password'),
- t('core', 'Good password'),
- t('core', 'Strong password'),
- ],
- drawTitles: true,
- nonce: btoa(getToken()),
- })
-
- $('#dbpass').showPassword().keyup()
- $('.toggle-password').click(function(event) {
- event.preventDefault()
- const currentValue = $(this).parent().children('input').attr('type')
- if (currentValue === 'password') {
- $(this).parent().children('input').attr('type', 'text')
- } else {
- $(this).parent().children('input').attr('type', 'password')
- }
- })
-})
diff --git a/core/src/install.ts b/core/src/install.ts
new file mode 100644
index 00000000000..4ef608ec2bd
--- /dev/null
+++ b/core/src/install.ts
@@ -0,0 +1,43 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import Setup from './views/Setup.vue'
+
+type Error = {
+ error: string
+ hint: string
+}
+
+export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci'
+
+export type SetupConfig = {
+ adminlogin: string
+ adminpass: string
+ directory: string
+ dbuser: string
+ dbpass: string
+ dbname: string
+ dbtablespace: string
+ dbhost: string
+ dbtype: DbType | ''
+
+ databases: Partial<Record<DbType, string>>
+
+ hasAutoconfig: boolean
+ htaccessWorking: boolean
+ serverRoot: string
+
+ errors: string[]|Error[]
+}
+
+export type SetupLinks = {
+ adminInstall: string
+ adminSourceInstall: string
+ adminDBConfiguration: string
+}
+
+const SetupVue = Vue.extend(Setup)
+new SetupVue().$mount('#content')
diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js
index c2868e2728a..1e9e06515a6 100644
--- a/core/src/jquery/requesttoken.js
+++ b/core/src/jquery/requesttoken.js
@@ -5,11 +5,11 @@
import $ from 'jquery'
-import { getToken } from '../OC/requesttoken.js'
+import { getRequestToken } from '../OC/requesttoken.ts'
$(document).on('ajaxSend', function(elm, xhr, settings) {
if (settings.crossDomain === false) {
- xhr.setRequestHeader('requesttoken', getToken())
+ xhr.setRequestHeader('requesttoken', getRequestToken())
xhr.setRequestHeader('OCS-APIREQUEST', 'true')
}
})
diff --git a/core/src/public-page-user-menu.ts b/core/src/public-page-user-menu.ts
new file mode 100644
index 00000000000..25024271fb5
--- /dev/null
+++ b/core/src/public-page-user-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageUserMenu from './views/PublicPageUserMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageUserMenu)
+const instance = new View()
+instance.$mount('#public-page-user-menu')
diff --git a/core/src/services/WebAuthnAuthenticationService.ts b/core/src/services/WebAuthnAuthenticationService.ts
index 82a07ae35ad..df1837254ad 100644
--- a/core/src/services/WebAuthnAuthenticationService.ts
+++ b/core/src/services/WebAuthnAuthenticationService.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
+import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
import { startAuthentication as startWebauthnAuthentication } from '@simplewebauthn/browser'
import { generateUrl } from '@nextcloud/router'
@@ -27,7 +27,7 @@ export async function startAuthentication(loginName: string) {
logger.error('No valid credentials returned for webauthn')
throw new NoValidCredentials()
}
- return await startWebauthnAuthentication(data)
+ return await startWebauthnAuthentication({ optionsJSON: data })
}
/**
diff --git a/core/src/session-heartbeat.js b/core/src/session-heartbeat.js
deleted file mode 100644
index 3bd4d6b9ccd..00000000000
--- a/core/src/session-heartbeat.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import $ from 'jquery'
-import { emit } from '@nextcloud/event-bus'
-import { loadState } from '@nextcloud/initial-state'
-import { getCurrentUser } from '@nextcloud/auth'
-import { generateUrl } from '@nextcloud/router'
-
-import OC from './OC/index.js'
-import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken.js'
-
-let config = null
-/**
- * The legacy jsunit tests overwrite OC.config before calling initCore
- * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat
- */
-const loadConfig = () => {
- try {
- config = loadState('core', 'config')
- } catch (e) {
- // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls
- config = OC.config
- }
-}
-
-/**
- * session heartbeat (defaults to enabled)
- *
- * @return {boolean}
- */
-const keepSessionAlive = () => {
- return config.session_keepalive === undefined
- || !!config.session_keepalive
-}
-
-/**
- * get interval in seconds
- *
- * @return {number}
- */
-const getInterval = () => {
- let interval = NaN
- if (config.session_lifetime) {
- interval = Math.floor(config.session_lifetime / 2)
- }
-
- // minimum one minute, max 24 hours, default 15 minutes
- return Math.min(
- 24 * 3600,
- Math.max(
- 60,
- isNaN(interval) ? 900 : interval,
- ),
- )
-}
-
-const getToken = async () => {
- const url = generateUrl('/csrftoken')
-
- // Not using Axios here as Axios is not stubbable with the sinon fake server
- // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises
- // see js/tests/specs/coreSpec.js for the tests
- const resp = await $.get(url)
-
- return resp.token
-}
-
-const poll = async () => {
- try {
- const token = await getToken()
- setRequestToken(token)
- } catch (e) {
- console.error('session heartbeat failed', e)
- }
-}
-
-const startPolling = () => {
- const interval = setInterval(poll, getInterval() * 1000)
-
- console.info('session heartbeat polling started')
-
- return interval
-}
-
-const registerAutoLogout = () => {
- if (!config.auto_logout || !getCurrentUser()) {
- return
- }
-
- let lastActive = Date.now()
- window.addEventListener('mousemove', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('touchstart', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('storage', e => {
- if (e.key !== 'lastActive') {
- return
- }
- lastActive = e.newValue
- })
-
- let intervalId = 0
- const logoutCheck = () => {
- const timeout = Date.now() - config.session_lifetime * 1000
- if (lastActive < timeout) {
- clearTimeout(intervalId)
- console.info('Inactivity timout reached, logging out')
- const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
- window.location = logoutUrl
- }
- }
- intervalId = setInterval(logoutCheck, 1000)
-}
-
-/**
- * Calls the server periodically to ensure that session and CSRF
- * token doesn't expire
- */
-export const initSessionHeartBeat = () => {
- loadConfig()
-
- registerAutoLogout()
-
- if (!keepSessionAlive()) {
- console.info('session heartbeat disabled')
- return
- }
- let interval = startPolling()
-
- window.addEventListener('online', async () => {
- console.info('browser is online again, resuming heartbeat')
- interval = startPolling()
- try {
- await poll()
- console.info('session token successfully updated after resuming network')
-
- // Let apps know we're online and requests will have the new token
- emit('networkOnline', {
- success: true,
- })
- } catch (e) {
- console.error('could not update session token after resuming network', e)
-
- // Let apps know we're online but requests might have an outdated token
- emit('networkOnline', {
- success: false,
- })
- }
- })
- window.addEventListener('offline', () => {
- console.info('browser is offline, stopping heartbeat')
-
- // Let apps know we're offline
- emit('networkOffline', {})
-
- clearInterval(interval)
- console.info('session heartbeat polling stopped')
- })
-}
diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts
new file mode 100644
index 00000000000..42a9bfccef7
--- /dev/null
+++ b/core/src/session-heartbeat.ts
@@ -0,0 +1,158 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
+import {
+ fetchRequestToken,
+ getRequestToken,
+} from './OC/requesttoken.ts'
+import logger from './logger.js'
+
+interface OcJsConfig {
+ auto_logout: boolean
+ session_keepalive: boolean
+ session_lifetime: number
+}
+
+// This is always set, exception would be e.g. error pages where this is undefined
+const {
+ auto_logout: autoLogout,
+ session_keepalive: keepSessionAlive,
+ session_lifetime: sessionLifetime,
+} = loadState<Partial<OcJsConfig>>('core', 'config', {})
+
+/**
+ * Calls the server periodically to ensure that session and CSRF
+ * token doesn't expire
+ */
+export function initSessionHeartBeat() {
+ registerAutoLogout()
+
+ if (!keepSessionAlive) {
+ logger.info('Session heartbeat disabled')
+ return
+ }
+
+ let interval = startPolling()
+ window.addEventListener('online', async () => {
+ logger.info('Browser is online again, resuming heartbeat')
+
+ interval = startPolling()
+ try {
+ await poll()
+ logger.info('Session token successfully updated after resuming network')
+
+ // Let apps know we're online and requests will have the new token
+ emit('networkOnline', {
+ success: true,
+ })
+ } catch (error) {
+ logger.error('could not update session token after resuming network', { error })
+
+ // Let apps know we're online but requests might have an outdated token
+ emit('networkOnline', {
+ success: false,
+ })
+ }
+ })
+
+ window.addEventListener('offline', () => {
+ logger.info('Browser is offline, stopping heartbeat')
+
+ // Let apps know we're offline
+ emit('networkOffline', {})
+
+ clearInterval(interval)
+ logger.info('Session heartbeat polling stopped')
+ })
+}
+
+/**
+ * Get interval in seconds
+ */
+function getInterval(): number {
+ const interval = sessionLifetime
+ ? Math.floor(sessionLifetime / 2)
+ : 900
+
+ // minimum one minute, max 24 hours, default 15 minutes
+ return Math.min(
+ 24 * 3600,
+ Math.max(
+ 60,
+ interval,
+ ),
+ )
+}
+
+/**
+ * Poll the CSRF token for changes.
+ * This will also extend the current session if needed.
+ */
+async function poll() {
+ try {
+ await fetchRequestToken()
+ } catch (error) {
+ logger.error('session heartbeat failed', { error })
+ }
+}
+
+/**
+ * Start an window interval with the polling as the callback.
+ *
+ * @return The interval id
+ */
+function startPolling(): number {
+ const interval = window.setInterval(poll, getInterval() * 1000)
+
+ logger.info('session heartbeat polling started')
+ return interval
+}
+
+/**
+ * If enabled this will register event listeners to track if a user is active.
+ * If not the user will be automatically logged out after the configured IDLE time.
+ */
+function registerAutoLogout() {
+ if (!autoLogout || !getCurrentUser()) {
+ return
+ }
+
+ let lastActive = Date.now()
+ window.addEventListener('mousemove', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('touchstart', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('storage', (event) => {
+ if (event.key !== 'lastActive') {
+ return
+ }
+ if (event.newValue === null) {
+ return
+ }
+ lastActive = JSON.parse(event.newValue)
+ })
+
+ let intervalId = 0
+ const logoutCheck = () => {
+ const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000
+ if (lastActive < timeout) {
+ clearTimeout(intervalId)
+ logger.info('Inactivity timout reached, logging out')
+ const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
+ window.location.href = logoutUrl
+ }
+ }
+ intervalId = window.setInterval(logoutCheck, 1000)
+}
diff --git a/core/src/store/unified-search-external-filters.js b/core/src/store/unified-search-external-filters.js
index 996a882e25f..55de34b8b2a 100644
--- a/core/src/store/unified-search-external-filters.js
+++ b/core/src/store/unified-search-external-filters.js
@@ -4,16 +4,14 @@
*/
import { defineStore } from 'pinia'
-export const useSearchStore = defineStore({
- id: 'search',
-
+export const useSearchStore = defineStore('search', {
state: () => ({
externalFilters: [],
}),
actions: {
- registerExternalFilter({ id, appId, label, callback, icon }) {
- this.externalFilters.push({ id, appId, name: label, callback, icon, isPluginFilter: true })
+ registerExternalFilter({ id, appId, searchFrom, label, callback, icon }) {
+ this.externalFilters.push({ id, appId, searchFrom, name: label, callback, icon, isPluginFilter: true })
},
},
})
diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js
deleted file mode 100644
index 36833742d14..00000000000
--- a/core/src/tests/OC/requesttoken.spec.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { manageToken, setToken } from '../../OC/requesttoken.js'
-
-const eventbus = vi.hoisted(() => ({ emit: vi.fn() }))
-vi.mock('@nextcloud/event-bus', () => eventbus)
-
-describe('request token', () => {
-
- let emit
- let manager
- const token = 'abc123'
-
- beforeEach(() => {
- emit = vi.fn()
- const head = window.document.getElementsByTagName('head')[0]
- head.setAttribute('data-requesttoken', token)
-
- manager = manageToken(window.document, emit)
- })
-
- test('reads the token from the document', () => {
- expect(manager.getToken()).toBe('abc123')
- })
-
- test('remembers the updated token', () => {
- manager.setToken('bca321')
-
- expect(manager.getToken()).toBe('bca321')
- })
-
- describe('@nextcloud/auth integration', () => {
- test('fires off an event for @nextcloud/auth', () => {
- setToken('123')
-
- expect(eventbus.emit).toHaveBeenCalledWith('csrf-token-update', { token: '123' })
- })
- })
-
-})
diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts
new file mode 100644
index 00000000000..8f92dbed153
--- /dev/null
+++ b/core/src/tests/OC/requesttoken.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { setupServer } from 'msw/node'
+import { http, HttpResponse } from 'msw'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts'
+
+const eventbus = vi.hoisted(() => ({ emit: vi.fn() }))
+vi.mock('@nextcloud/event-bus', () => eventbus)
+
+const server = setupServer()
+
+describe('getRequestToken', () => {
+ it('can read the token from DOM', () => {
+ mockToken('tokenmock-123')
+ expect(getRequestToken()).toBe('tokenmock-123')
+ })
+
+ it('can handle missing token', () => {
+ mockToken(undefined)
+ expect(getRequestToken()).toBeUndefined()
+ })
+})
+
+describe('setRequestToken', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('does emit an event on change', () => {
+ setRequestToken('new-token')
+ expect(eventbus.emit).toBeCalledTimes(1)
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('does set the new token to the DOM', () => {
+ setRequestToken('new-token')
+ expect(document.head.dataset.requesttoken).toBe('new-token')
+ })
+
+ it('does remember the new token', () => {
+ mockToken('old-token')
+ setRequestToken('new-token')
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('throws if the token is not a string', () => {
+ // @ts-expect-error mocking
+ expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given')
+ })
+
+ it('throws if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ })
+
+ it('does not emit an event if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ expect(eventbus.emit).not.toBeCalled()
+ })
+})
+
+describe('fetchRequestToken', () => {
+ const successfullCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json({ token: 'new-token' })
+ })
+ const forbiddenCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 403 })
+ })
+ const serverErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 500 })
+ })
+ const networkErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return new HttpResponse(null, { type: 'error' })
+ })
+
+ beforeAll(() => {
+ server.listen()
+ })
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('correctly parses response', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ const token = await fetchRequestToken()
+ expect(token).toBe('new-token')
+ })
+
+ it('sets the token', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ await fetchRequestToken()
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('does emit an event', async () => {
+ server.use(successfullCsrf)
+
+ await fetchRequestToken()
+ expect(eventbus.emit).toHaveBeenCalledOnce()
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('handles 403 error due to invalid cookies', async () => {
+ server.use(forbiddenCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles server error', async () => {
+ server.use(serverErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles network error', async () => {
+ server.use(networkErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrow()
+ expect(getRequestToken()).toBe('oldToken')
+ })
+})
+
+/**
+ * Mock the request token directly so we can test reading it.
+ *
+ * @param token - The CSRF token to mock
+ */
+function mockToken(token?: string) {
+ if (token === undefined) {
+ delete document.head.dataset.requesttoken
+ } else {
+ document.head.dataset.requesttoken = token
+ }
+}
diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts
new file mode 100644
index 00000000000..61b82d92887
--- /dev/null
+++ b/core/src/tests/OC/session-heartbeat.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const requestToken = vi.hoisted(() => ({
+ fetchRequestToken: vi.fn<() => Promise<string>>(),
+ setRequestToken: vi.fn<(token: string) => void>(),
+}))
+vi.mock('../../OC/requesttoken.ts', () => requestToken)
+
+const initialState = vi.hoisted(() => ({ loadState: vi.fn() }))
+vi.mock('@nextcloud/initial-state', () => initialState)
+
+describe('Session heartbeat', () => {
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
+
+ beforeEach(() => {
+ vi.clearAllTimers()
+ vi.resetModules()
+ vi.resetAllMocks()
+ })
+
+ it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // reach past half, one call
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // almost there to the next, still one
+ await vi.advanceTimersByTimeAsync(135 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // past it, second call
+ await vi.advanceTimersByTimeAsync(5 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(2)
+ })
+
+ it('does not send heartbeat when heartbeat disabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: false,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // more than one, still nothing
+ await vi.advanceTimersByTimeAsync(300 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 55,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 30 / 55 seconds
+ await vi.advanceTimersByTimeAsync(30 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // 59 / 55 seconds should not be called except it does not limit
+ await vi.advanceTimersByTimeAsync(29 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // now one minute has passed
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 50 * 60 * 60,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 23 hours
+ await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // one day - it should be called now
+ await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+})
diff --git a/core/src/twofactor-request-token.ts b/core/src/twofactor-request-token.ts
new file mode 100644
index 00000000000..868ceec01e9
--- /dev/null
+++ b/core/src/twofactor-request-token.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { onRequestTokenUpdate } from '@nextcloud/auth'
+import { getBaseUrl } from '@nextcloud/router'
+
+document.addEventListener('DOMContentLoaded', () => {
+ onRequestTokenUpdate((token) => {
+ const cancelLink = window.document.getElementById('cancel-login')
+ if (!cancelLink) {
+ return
+ }
+
+ const href = cancelLink.getAttribute('href')
+ if (!href) {
+ return
+ }
+
+ const parsedHref = new URL(href, getBaseUrl())
+ parsedHref.searchParams.set('requesttoken', token)
+ cancelLink.setAttribute('href', parsedHref.pathname + parsedHref.search)
+ })
+})
diff --git a/core/src/unified-search.ts b/core/src/unified-search.ts
index fd5f9cb1fdf..a13b1036da1 100644
--- a/core/src/unified-search.ts
+++ b/core/src/unified-search.ts
@@ -36,6 +36,7 @@ Vue.mixin({
interface UnifiedSearchAction {
id: string;
appId: string;
+ searchFrom: string;
label: string;
icon: string;
callback: () => void;
@@ -44,9 +45,9 @@ interface UnifiedSearchAction {
// Register the add/register filter action API globally
window.OCA = window.OCA || {}
window.OCA.UnifiedSearch = {
- registerFilterAction: ({ id, appId, label, callback, icon }: UnifiedSearchAction) => {
+ registerFilterAction: ({ id, appId, searchFrom, label, callback, icon }: UnifiedSearchAction) => {
const searchStore = useSearchStore()
- searchStore.registerExternalFilter({ id, appId, label, callback, icon })
+ searchStore.registerExternalFilter({ id, appId, searchFrom, label, callback, icon })
},
}
diff --git a/core/src/utils/xhr-request.js b/core/src/utils/xhr-request.js
index 68641ebc006..7f074a857a6 100644
--- a/core/src/utils/xhr-request.js
+++ b/core/src/utils/xhr-request.js
@@ -5,6 +5,7 @@
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.js'
/**
*
@@ -30,7 +31,7 @@ const isNextcloudUrl = (url) => {
/**
* Check if a user was logged in but is now logged-out.
* If this is the case then the user will be forwarded to the login page.
- * @returns {Promise<void>}
+ * @return {Promise<void>}
*/
async function checkLoginStatus() {
// skip if no logged in user
@@ -51,6 +52,7 @@ async function checkLoginStatus() {
const { status } = await window.fetch(generateUrl('/apps/files'))
if (status === 401) {
console.warn('User session was terminated, forwarding to login page.')
+ await wipeBrowserStorages()
window.location = generateUrl('/login?redirect_url={url}', {
url: window.location.pathname + window.location.search + window.location.hash,
})
@@ -63,6 +65,24 @@ async function checkLoginStatus() {
}
/**
+ * Clear all Browser storages connected to current origin.
+ * @return {Promise<void>}
+ */
+export async function wipeBrowserStorages() {
+ try {
+ window.localStorage.clear()
+ window.sessionStorage.clear()
+ const indexedDBList = await window.indexedDB.databases()
+ for (const indexedDB of indexedDBList) {
+ await window.indexedDB.deleteDatabase(indexedDB.name)
+ }
+ logger.debug('Browser storages cleared')
+ } catch (error) {
+ logger.error('Could not clear browser storages', { error })
+ }
+}
+
+/**
* Intercept XMLHttpRequest and fetch API calls to add X-Requested-With header
*
* This is also done in @nextcloud/axios but not all requests pass through that
diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue
index 0eb6a76e4dd..5b7ead636bd 100644
--- a/core/src/views/AccountMenu.vue
+++ b/core/src/views/AccountMenu.vue
@@ -47,8 +47,8 @@ import { getAllStatusOptions } from '../../../apps/user_status/src/services/stat
import axios from '@nextcloud/axios'
import logger from '../logger.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
@@ -197,27 +197,15 @@ export default defineComponent({
}
.account-menu {
- :deep(button) {
- // Normally header menus are slightly translucent when not active
- // this is generally ok but for the avatar this is weird so fix the opacity
- opacity: 1 !important;
-
- // The avatar is just the "icon" of the button
- // So we add the focus-visible manually
- &:focus-visible {
- .account-menu__avatar {
- border: var(--border-width-input-focused) solid var(--color-background-plain-text);
- }
- }
- }
-
- // Ensure we do not wast space, as the header menu sets a default width of 350px
- :deep(.header-menu__content) {
- width: fit-content !important;
- }
-
&__avatar {
+ --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%);
+ outline: var(--account-menu-outline);
+ position: fixed;
+ // do not apply the alpha mask on the avatar div
+ mask: none !important;
+
&:hover {
+ --account-menu-outline: none;
// Add hover styles similar to the focus-visible style
border: var(--border-width-input-focused) solid var(--color-background-plain-text);
}
@@ -235,5 +223,25 @@ export default defineComponent({
flex: 0 1;
}
}
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ :deep(button) {
+ // Normally header menus are slightly translucent when not active
+ // this is generally ok but for the avatar this is weird so fix the opacity
+ opacity: 1 !important;
+
+ // The avatar is just the "icon" of the button
+ // So we add the focus-visible manually
+ &:focus-visible {
+ .account-menu__avatar {
+ --account-menu-outline: none;
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+ }
}
</style>
diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue
index b1f8a96f730..924ddcea56b 100644
--- a/core/src/views/ContactsMenu.vue
+++ b/core/src/views/ContactsMenu.vue
@@ -9,7 +9,7 @@
:aria-label="t('core', 'Search contacts')"
@open="handleOpen">
<template #trigger>
- <Contacts class="contactsmenu__trigger-icon" :size="20" />
+ <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
</template>
<div class="contactsmenu__menu">
<div class="contactsmenu__menu__input-wrapper">
@@ -27,7 +27,7 @@
</div>
<NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')">
<template #icon>
- <Magnify />
+ <NcIconSvgWrapper :path="mdiMagnify" />
</template>
</NcEmptyContent>
<NcEmptyContent v-else-if="loadingText" :name="loadingText">
@@ -37,7 +37,7 @@
</NcEmptyContent>
<NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')">
<template #icon>
- <Magnify />
+ <NcIconSvgWrapper :path="mdiMagnify" />
</template>
</NcEmptyContent>
<div v-else class="contactsmenu__menu__content">
@@ -62,39 +62,46 @@
</template>
<script>
+import { mdiContacts, mdiMagnify } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import Contacts from 'vue-material-design-icons/Contacts.vue'
import debounce from 'debounce'
-import { getCurrentUser } from '@nextcloud/auth'
-import { generateUrl } from '@nextcloud/router'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import { translate as t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import Contact from '../components/ContactsMenu/Contact.vue'
import logger from '../logger.js'
import Nextcloud from '../mixins/Nextcloud.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
export default {
name: 'ContactsMenu',
components: {
Contact,
- Contacts,
- Magnify,
NcButton,
NcEmptyContent,
NcHeaderMenu,
+ NcIconSvgWrapper,
NcLoadingIcon,
NcTextField,
},
mixins: [Nextcloud],
+ setup() {
+ return {
+ mdiContacts,
+ mdiMagnify,
+ }
+ },
+
data() {
const user = getCurrentUser()
return {
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
index 0bb55dc53e4..1277970ba0e 100644
--- a/core/src/views/LegacyUnifiedSearch.vue
+++ b/core/src/views/LegacyUnifiedSearch.vue
@@ -108,11 +108,11 @@ import debounce from 'debounce'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { showError } from '@nextcloud/dialogs'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import Magnify from 'vue-material-design-icons/Magnify.vue'
@@ -270,7 +270,7 @@ export default {
return n('core',
'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
this.minSearchLength,
{ minSearchLength: this.minSearchLength })
},
diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue
index a13109bb766..a6fe8442779 100644
--- a/core/src/views/Login.vue
+++ b/core/src/views/Login.vue
@@ -7,7 +7,7 @@
<div class="guest-box login-box">
<template v-if="!hideLoginForm || directLogin">
<transition name="fade" mode="out-in">
- <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''">
+ <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper">
<LoginForm :username.sync="user"
:redirect-url="redirectUrl"
:direct-login="directLogin"
@@ -17,40 +17,30 @@
:auto-complete-allowed="autoCompleteAllowed"
:email-states="emailStates"
@submit="loading = true" />
- <a v-if="canResetPassword && resetPasswordLink !== ''"
+ <NcButton v-if="hasPasswordless"
+ type="tertiary"
+ wide
+ @click.prevent="passwordlessLogin = true">
+ {{ t('core', 'Log in with a device') }}
+ </NcButton>
+ <NcButton v-if="canResetPassword && resetPasswordLink !== ''"
id="lost-password"
- class="login-box__link"
- :href="resetPasswordLink">
+ :href="resetPasswordLink"
+ type="tertiary-no-background"
+ wide>
{{ t('core', 'Forgot password?') }}
- </a>
- <a v-else-if="canResetPassword && !resetPassword"
+ </NcButton>
+ <NcButton v-else-if="canResetPassword && !resetPassword"
id="lost-password"
- class="login-box__link"
- :href="resetPasswordLink"
+ type="tertiary"
+ wide
@click.prevent="resetPassword = true">
{{ t('core', 'Forgot password?') }}
- </a>
- <template v-if="hasPasswordless">
- <div v-if="countAlternativeLogins"
- class="alternative-logins">
- <a v-if="hasPasswordless"
- class="button"
- :class="{ 'single-alt-login-option': countAlternativeLogins }"
- href="#"
- @click.prevent="passwordlessLogin = true">
- {{ t('core', 'Log in with a device') }}
- </a>
- </div>
- <a v-else
- href="#"
- @click.prevent="passwordlessLogin = true">
- {{ t('core', 'Log in with a device') }}
- </a>
- </template>
+ </NcButton>
</div>
<div v-else-if="!loading && passwordlessLogin"
key="reset-pw-less"
- class="login-additional login-passwordless">
+ class="login-additional login-box__wrapper">
<PasswordLessLoginForm :username.sync="user"
:redirect-url="redirectUrl"
:auto-complete-allowed="autoCompleteAllowed"
@@ -89,7 +79,7 @@
</transition>
</template>
- <div id="alternative-logins" class="alternative-logins">
+ <div id="alternative-logins" class="login-box__alternative-logins">
<NcButton v-for="(alternativeLogin, index) in alternativeLogins"
:key="index"
type="secondary"
@@ -105,24 +95,21 @@
<script>
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+
import queryString from 'query-string'
import LoginForm from '../components/login/LoginForm.vue'
import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import { wipeBrowserStorages } from '../utils/xhr-request.js'
const query = queryString.parse(location.search)
if (query.clear === '1') {
- try {
- window.localStorage.clear()
- window.sessionStorage.clear()
- console.debug('Browser storage cleared')
- } catch (e) {
- console.error('Could not clear browser storage', e)
- }
+ wipeBrowserStorages()
}
export default {
@@ -167,29 +154,28 @@ export default {
methods: {
passwordResetFinished() {
- this.resetPasswordTarget = ''
- this.directLogin = true
+ window.location.href = generateUrl('login')
},
},
}
</script>
-<style lang="scss">
-body {
- font-size: var(--default-font-size);
-}
-
+<style scoped lang="scss">
.login-box {
// Same size as dashboard panels
width: 320px;
box-sizing: border-box;
- &__link {
- display: block;
- padding: 1rem;
- font-size: var(--default-font-size);
- text-align: center;
- font-weight: normal !important;
+ &__wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: calc(2 * var(--default-grid-baseline));
+ }
+
+ &__alternative-logins {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
}
}
@@ -200,20 +186,4 @@ body {
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
-
-.alternative-logins {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-
- .button-vue {
- box-sizing: border-box;
- }
-}
-
-.login-passwordless {
- .button-vue {
- margin-top: 0.5rem;
- }
-}
</style>
diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue
index a9ff78a7c5f..a05f3a6b889 100644
--- a/core/src/views/PublicPageMenu.vue
+++ b/core/src/views/PublicPageMenu.vue
@@ -37,13 +37,13 @@
</template>
<script setup lang="ts">
-import { spawnDialog } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
-import { useIsSmallMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
+import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import { computed, ref, type Ref } from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import IconMore from 'vue-material-design-icons/DotsHorizontal.vue'
import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue'
import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue'
diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue
new file mode 100644
index 00000000000..7bd6521e7aa
--- /dev/null
+++ b/core/src/views/PublicPageUserMenu.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcHeaderMenu id="public-page-user-menu"
+ class="public-page-user-menu"
+ is-nav
+ :aria-label="t('core', 'User menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <NcAvatar class="public-page-user-menu__avatar"
+ disable-menu
+ disable-tooltip
+ is-guest
+ :user="displayName || '?'" />
+ </template>
+
+ <!-- Privacy notice -->
+ <NcNoteCard class="public-page-user-menu__list-note"
+ :text="privacyNotice"
+ type="info" />
+
+ <ul class="public-page-user-menu__list">
+ <!-- Nickname dialog -->
+ <AccountMenuEntry id="set-nickname"
+ :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')"
+ href="#"
+ @click.prevent.stop="setNickname">
+ <template #icon>
+ <IconAccount />
+ </template>
+ </AccountMenuEntry>
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import type { NextcloudUser } from '@nextcloud/auth'
+
+import '@nextcloud/dialogs/style.css'
+import { defineComponent } from 'vue'
+import { getGuestUser } from '@nextcloud/auth'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import IconAccount from 'vue-material-design-icons/AccountOutline.vue'
+
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+export default defineComponent({
+ name: 'PublicPageUserMenu',
+ components: {
+ AccountMenuEntry,
+ IconAccount,
+ NcAvatar,
+ NcHeaderMenu,
+ NcNoteCard,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ displayName: getGuestUser().displayName,
+ }
+ },
+
+ computed: {
+ avatarDescription(): string {
+ return t('core', 'User menu')
+ },
+
+ privacyNotice(): string {
+ return this.displayName
+ ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName })
+ : t('core', 'You are currently not identified.')
+ },
+ },
+
+ mounted() {
+ subscribe('user:info:changed', (user: NextcloudUser) => {
+ this.displayName = user.displayName || ''
+ })
+ },
+
+ methods: {
+ setNickname() {
+ showGuestUserPrompt({
+ nickname: this.displayName,
+ cancellable: true,
+ })
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.public-page-user-menu {
+ &, * {
+ box-sizing: border-box;
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ &__list-note {
+ padding-block: 5px !important;
+ padding-inline: 5px !important;
+ max-width: 300px;
+ margin: 5px !important;
+ margin-bottom: 0 !important;
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ width: 100%;
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+}
+</style>
diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts
new file mode 100644
index 00000000000..f252801c4d8
--- /dev/null
+++ b/core/src/views/Setup.cy.ts
@@ -0,0 +1,369 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { SetupConfig, SetupLinks } from '../install'
+import SetupView from './Setup.vue'
+
+import '../../css/guest.css'
+
+const defaultConfig = Object.freeze({
+ adminlogin: '',
+ adminpass: '',
+ dbuser: '',
+ dbpass: '',
+ dbname: '',
+ dbtablespace: '',
+ dbhost: '',
+ dbtype: '',
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ },
+ directory: '',
+ hasAutoconfig: false,
+ htaccessWorking: true,
+ serverRoot: '/var/www/html',
+ errors: [],
+}) as SetupConfig
+
+const links = {
+ adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
+ adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
+ adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
+} as SetupLinks
+
+describe('Default setup page', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders default config', () => {
+ cy.mockInitialState('core', 'config', defaultConfig)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form]').scrollIntoView()
+ cy.get('[data-cy-setup-form]').should('be.visible')
+
+ // Single note is the footer help
+ cy.get('[data-cy-setup-form-note]')
+ .should('have.length', 1)
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation')
+
+ // DB radio selectors
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('exist')
+ .find('input')
+ .should('be.checked')
+
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist')
+
+ // Sqlite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+
+ // admin login, password, data directory and 3 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 6)
+ })
+
+ it('Renders single DB sqlite', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only sqlite
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Two warnings: sqlite and single db support
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+
+ // Admin login, password and data directory
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 3)
+ })
+
+ it('Renders single DB mysql', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ mysql: 'MySQL/MariaDB',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only mysql
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Single db support warning
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+ .invoke('html')
+ .should('contains', links.adminSourceInstall)
+
+ // No SQLite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('not.exist')
+
+ // Admin login, password, data directory, db user,
+ // db password, db name and db host
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+ })
+
+ it('Changes fields from sqlite to mysql then oci', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ oci: 'Oracle',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // SQLite selected
+ cy.get('[data-cy-setup-form-field="dbtype-sqlite"]')
+ .should('be.visible')
+ .find('input')
+ .should('be.checked')
+
+ // Admin login, password, data directory and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+
+ // Change to MySQL
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 11)
+
+ // Change to Oracle
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db table space, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 12)
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('be.visible')
+ })
+})
+
+describe('Setup page with errors and warning', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders error from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders errors from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ 'Error message 1',
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 2)
+ cy.get('[data-cy-setup-form-note="error"]').eq(0)
+ .should('contain', 'Error message 1')
+ cy.get('[data-cy-setup-form-note="error"]').eq(1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders all the submitted fields on error', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('input[data-cy-setup-form-field="adminlogin"]')
+ .should('have.value', 'admin')
+ cy.get('input[data-cy-setup-form-field="adminpass"]')
+ .should('have.value', 'password')
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input')
+ .should('be.checked')
+ cy.get('input[data-cy-setup-form-field="dbname"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbuser"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbpass"]')
+ .should('have.value', 'password')
+ cy.get('input[data-cy-setup-form-field="dbhost"]')
+ .should('have.value', 'localhost')
+ cy.get('input[data-cy-setup-form-field="directory"]')
+ .should('have.value', '/var/www/html/nextcloud')
+ })
+
+ it('Renders the htaccess warning', () => {
+ const config = {
+ ...defaultConfig,
+ htaccessWorking: false,
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form-note="htaccess"]')
+ .should('be.visible')
+ .should('contain', 'Security warning')
+ .invoke('html')
+ .should('contains', links.adminInstall)
+ })
+})
+
+describe('Setup page with autoconfig', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders autoconfig', () => {
+ const config = {
+ ...defaultConfig,
+ hasAutoconfig: true,
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Autoconfig info note
+ cy.get('[data-cy-setup-form-note="autoconfig"]')
+ .should('be.visible')
+ .should('contain', 'Autoconfig file detected')
+
+ // Database and storage section is hidden as already set in autoconfig
+ cy.get('[data-cy-setup-form-advanced-config]').should('be.visible')
+ .invoke('attr', 'open')
+ .should('equal', undefined)
+
+ // Oracle tablespace is hidden
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('not.exist')
+ })
+})
+
+describe('Submit a full form sends the data', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Submits a full form', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ dbtablespace: 'tablespace',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+
+ cy.intercept('POST', '**', {
+ delay: 2000,
+ }).as('setup')
+
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Not chaining breaks the test as the POST prevents the element from being retrieved twice
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.get('[data-cy-setup-form-submit]')
+ .click()
+ .invoke('attr', 'disabled')
+ .should('equal', 'disabled', { timeout: 500 })
+
+ cy.wait('@setup')
+ .its('request.body')
+ .should('deep.equal', new URLSearchParams({
+ adminlogin: 'admin',
+ adminpass: 'password',
+ directory: '/var/www/html/nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbname: 'nextcloud',
+ dbhost: 'localhost',
+ }).toString())
+ })
+})
diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue
new file mode 100644
index 00000000000..50ec0da9035
--- /dev/null
+++ b/core/src/views/Setup.vue
@@ -0,0 +1,460 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <form ref="form"
+ class="setup-form"
+ :class="{ 'setup-form--loading': loading }"
+ action=""
+ data-cy-setup-form
+ method="POST"
+ @submit="onSubmit">
+ <!-- Autoconfig info -->
+ <NcNoteCard v-if="config.hasAutoconfig"
+ :heading="t('core', 'Autoconfig file detected')"
+ data-cy-setup-form-note="autoconfig"
+ type="success">
+ {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
+ </NcNoteCard>
+
+ <!-- Htaccess warning -->
+ <NcNoteCard v-if="config.htaccessWorking === false"
+ :heading="t('core', 'Security warning')"
+ data-cy-setup-form-note="htaccess"
+ type="warning">
+ <p v-html="htaccessWarning" />
+ </NcNoteCard>
+
+ <!-- Various errors -->
+ <NcNoteCard v-for="(error, index) in errors"
+ :key="index"
+ :heading="error.heading"
+ data-cy-setup-form-note="error"
+ type="error">
+ {{ error.message }}
+ </NcNoteCard>
+
+ <!-- Admin creation -->
+ <fieldset class="setup-form__administration">
+ <legend>{{ t('core', 'Create administration account') }}</legend>
+
+ <!-- Username -->
+ <NcTextField v-model="config.adminlogin"
+ :label="t('core', 'Administration account name')"
+ data-cy-setup-form-field="adminlogin"
+ name="adminlogin"
+ required />
+
+ <!-- Password -->
+ <NcPasswordField v-model="config.adminpass"
+ :label="t('core', 'Administration account password')"
+ data-cy-setup-form-field="adminpass"
+ name="adminpass"
+ required />
+
+ <!-- Password entropy -->
+ <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType">
+ {{ passwordHelperText }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Autoconfig toggle -->
+ <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config>
+ <summary>{{ t('core', 'Storage & database') }}</summary>
+
+ <!-- Data folder -->
+ <fieldset class="setup-form__data-folder">
+ <NcTextField v-model="config.directory"
+ :label="t('core', 'Data folder')"
+ :placeholder="config.serverRoot + '/data'"
+ required
+ autocomplete="off"
+ autocapitalize="none"
+ data-cy-setup-form-field="directory"
+ name="directory"
+ spellcheck="false" />
+ </fieldset>
+
+ <!-- Database -->
+ <fieldset class="setup-form__database">
+ <legend>{{ t('core', 'Database configuration') }}</legend>
+
+ <!-- Database type select -->
+ <fieldset class="setup-form__database-type">
+ <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
+ <NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
+ :key="db"
+ v-model="config.dbtype"
+ :button-variant="true"
+ :data-cy-setup-form-field="`dbtype-${db}`"
+ :value="db"
+ :button-variant-grouped="DBTypeGroupDirection"
+ name="dbtype"
+ type="radio">
+ {{ name }}
+ </NcCheckboxRadioSwitch>
+ </p>
+
+ <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning">
+ {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br>
+ {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
+ <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
+ {{ t('core', 'For more details check out the documentation.') }} ↗
+ </a>
+ </NcNoteCard>
+
+ <NcNoteCard v-if="config.dbtype === 'sqlite'"
+ :heading="t('core', 'Performance warning')"
+ data-cy-setup-form-db-note="sqlite"
+ type="warning">
+ {{ t('core', 'You chose SQLite as database.') }}<br>
+ {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
+ {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Database configuration -->
+ <fieldset v-if="config.dbtype !== 'sqlite'">
+ <NcTextField v-model="config.dbuser"
+ :label="t('core', 'Database user')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbuser"
+ name="dbuser"
+ spellcheck="false"
+ required />
+
+ <NcPasswordField v-model="config.dbpass"
+ :label="t('core', 'Database password')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbpass"
+ name="dbpass"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-model="config.dbname"
+ :label="t('core', 'Database name')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbname"
+ name="dbname"
+ pattern="[0-9a-zA-Z\$_\-]+"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-if="config.dbtype === 'oci'"
+ v-model="config.dbtablespace"
+ :label="t('core', 'Database tablespace')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbtablespace"
+ name="dbtablespace"
+ spellcheck="false" />
+
+ <NcTextField v-model="config.dbhost"
+ :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')"
+ :label="t('core', 'Database host')"
+ :placeholder="t('core', 'localhost')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbhost"
+ name="dbhost"
+ spellcheck="false" />
+ </fieldset>
+ </fieldset>
+ </details>
+
+ <!-- Submit -->
+ <NcButton class="setup-form__button"
+ :class="{ 'setup-form__button--loading': loading }"
+ :disabled="loading"
+ :loading="loading"
+ :wide="true"
+ alignment="center-reverse"
+ data-cy-setup-form-submit
+ native-type="submit"
+ type="primary">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconArrowRight v-else />
+ </template>
+ {{ loading ? t('core', 'Installing …') : t('core', 'Install') }}
+ </NcButton>
+
+ <!-- Help note -->
+ <NcNoteCard data-cy-setup-form-note="help" type="info">
+ {{ t('core', 'Need help?') }}
+ <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a>
+ </NcNoteCard>
+ </form>
+</template>
+<script lang="ts">
+import type { DbType, SetupConfig, SetupLinks } from '../install'
+
+import { defineComponent } from 'vue'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import DomPurify from 'dompurify'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+
+enum PasswordStrength {
+ VeryWeak,
+ Weak,
+ Moderate,
+ Strong,
+ VeryStrong,
+ ExtremelyStrong,
+}
+
+const checkPasswordEntropy = (password: string = ''): PasswordStrength => {
+ const uniqueCharacters = new Set(password)
+ const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
+ if (entropy < 16) {
+ return PasswordStrength.VeryWeak
+ } else if (entropy < 31) {
+ return PasswordStrength.Weak
+ } else if (entropy < 46) {
+ return PasswordStrength.Moderate
+ } else if (entropy < 61) {
+ return PasswordStrength.Strong
+ } else if (entropy < 76) {
+ return PasswordStrength.VeryStrong
+ }
+
+ return PasswordStrength.ExtremelyStrong
+}
+
+export default defineComponent({
+ name: 'Setup',
+
+ components: {
+ IconArrowRight,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ NcNoteCard,
+ NcPasswordField,
+ NcTextField,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ config: {} as SetupConfig,
+ links: {} as SetupLinks,
+ isValidAutoconfig: false,
+ loading: false,
+ }
+ },
+
+ computed: {
+ passwordHelperText(): string {
+ if (this.config?.adminpass === '') {
+ return ''
+ }
+
+ const passwordStrength = checkPasswordEntropy(this.config?.adminpass)
+ switch (passwordStrength) {
+ case PasswordStrength.VeryWeak:
+ return t('core', 'Password is too weak')
+ case PasswordStrength.Weak:
+ return t('core', 'Password is weak')
+ case PasswordStrength.Moderate:
+ return t('core', 'Password is average')
+ case PasswordStrength.Strong:
+ return t('core', 'Password is strong')
+ case PasswordStrength.VeryStrong:
+ return t('core', 'Password is very strong')
+ case PasswordStrength.ExtremelyStrong:
+ return t('core', 'Password is extremely strong')
+ }
+
+ return t('core', 'Unknown password strength')
+ },
+ passwordHelperType() {
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) {
+ return 'error'
+ }
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) {
+ return 'warning'
+ }
+ return 'success'
+ },
+
+ firstAndOnlyDatabase(): string|null {
+ const dbNames = Object.values(this.config?.databases || {})
+ if (dbNames.length === 1) {
+ return dbNames[0]
+ }
+
+ return null
+ },
+
+ DBTypeGroupDirection() {
+ const databases = Object.keys(this.config?.databases || {})
+ // If we have more than 3 databases, we want to display them vertically
+ if (databases.length > 3) {
+ return 'vertical'
+ }
+ return 'horizontal'
+ },
+
+ htaccessWarning(): string {
+ // We use v-html, let's make sure we're safe
+ const message = [
+ t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
+ t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
+ linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
+ linkEnd: '</a>',
+ }, { escape: false }),
+ ].join('<br>')
+ return DomPurify.sanitize(message)
+ },
+
+ errors() {
+ return (this.config?.errors || []).map(error => {
+ if (typeof error === 'string') {
+ return {
+ heading: '',
+ message: error,
+ }
+ }
+
+ // f no hint is set, we don't want to show a heading
+ if (error.hint === '') {
+ return {
+ heading: '',
+ message: error.error,
+ }
+ }
+
+ return {
+ heading: error.error,
+ message: error.hint,
+ }
+ })
+ },
+ },
+
+ beforeMount() {
+ // Needs to only read the state once we're mounted
+ // for Cypress to be properly initialized.
+ this.config = loadState<SetupConfig>('core', 'config')
+ this.links = loadState<SetupLinks>('core', 'links')
+
+ },
+
+ mounted() {
+ // Set the first database type as default if none is set
+ if (this.config.dbtype === '') {
+ this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
+ }
+
+ // Validate the legitimacy of the autoconfig
+ if (this.config.hasAutoconfig) {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.removeAttribute('required')
+ })
+
+ if (form.checkValidity() && this.config.errors.length === 0) {
+ this.isValidAutoconfig = true
+ } else {
+ this.isValidAutoconfig = false
+ }
+
+ // Restore the required attribute
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.setAttribute('required', 'true')
+ })
+ }
+ },
+
+ methods: {
+ async onSubmit() {
+ this.loading = true
+ },
+ },
+})
+</script>
+<style lang="scss">
+form {
+ padding: calc(3 * var(--default-grid-baseline));
+ color: var(--color-main-text);
+ border-radius: var(--border-radius-container);
+ background-color: var(--color-main-background-blur);
+ box-shadow: 0 0 10px var(--color-box-shadow);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+
+ max-width: 300px;
+ margin-bottom: 30px;
+
+ > fieldset:first-child,
+ > .notecard:first-child {
+ margin-top: 0;
+ }
+
+ > .notecard:last-child {
+ margin-bottom: 0;
+ }
+
+ fieldset,
+ details {
+ margin-block: 1rem;
+ }
+
+ .setup-form__button:not(.setup-form__button--loading) {
+ .material-design-icon {
+ transition: all linear var(--animation-quick);
+ }
+
+ &:hover .material-design-icon {
+ transform: translateX(0.2em);
+ }
+ }
+
+ // Db select required styling
+ .setup-form__database-type-select {
+ display: flex;
+ &--vertical {
+ flex-direction: column;
+ }
+ }
+
+}
+
+code {
+ background-color: var(--color-background-dark);
+ margin-top: 1rem;
+ padding: 0 0.3em;
+ border-radius: var(--border-radius);
+}
+
+// Various overrides
+.input-field {
+ margin-block-start: 1rem !important;
+}
+
+.notecard__heading {
+ font-size: inherit !important;
+}
+</style>
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
index 38b18814665..d7b2ca634eb 100644
--- a/core/src/views/UnifiedSearch.vue
+++ b/core/src/views/UnifiedSearch.vue
@@ -3,16 +3,14 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <div class="header-menu unified-search-menu">
- <NcButton v-show="!showLocalSearch"
- class="header-menu__trigger"
+ <div class="unified-search-menu">
+ <NcHeaderButton v-show="!showLocalSearch"
:aria-label="t('core', 'Unified search')"
- type="tertiary-no-background"
@click="toggleUnifiedSearch">
<template #icon>
- <Magnify class="header-menu__trigger-icon" :size="20" />
+ <NcIconSvgWrapper :path="mdiMagnify" />
</template>
- </NcButton>
+ </NcHeaderButton>
<UnifiedSearchLocalSearchBar v-if="supportsLocalSearch"
:open.sync="showLocalSearch"
:query.sync="queryText"
@@ -24,25 +22,24 @@
</template>
<script lang="ts">
+import { mdiMagnify } from '@mdi/js'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { translate } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
import { useBrowserLocation } from '@vueuse/core'
+import debounce from 'debounce'
import { defineComponent } from 'vue'
-
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
+import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue'
import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue'
-
-import debounce from 'debounce'
-import logger from '../logger'
+import logger from '../logger.js'
export default defineComponent({
name: 'UnifiedSearch',
components: {
- NcButton,
- Magnify,
+ NcHeaderButton,
+ NcIconSvgWrapper,
UnifiedSearchModal,
UnifiedSearchLocalSearchBar,
},
@@ -52,7 +49,9 @@ export default defineComponent({
return {
currentLocation,
- t: translate,
+
+ mdiMagnify,
+ t,
}
},
@@ -175,31 +174,9 @@ export default defineComponent({
<style lang="scss" scoped>
// this is needed to allow us overriding component styles (focus-visible)
-#header {
- .header-menu {
- display: flex;
- align-items: center;
- justify-content: center;
-
- &__trigger {
- height: var(--header-height);
- width: var(--header-height) !important;
-
- &:focus-visible {
- // align with other header menu entries
- outline: none !important;
- box-shadow: none !important;
- }
-
- &:not(:hover,:focus,:focus-visible) {
- opacity: .85;
- }
-
- &-icon {
- // ensure the icon has the correct color
- color: var(--color-background-plain-text) !important;
- }
- }
- }
+.unified-search-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
</style>
diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue
index d8d7dc55208..408cccf61e9 100644
--- a/core/src/views/UnsupportedBrowser.vue
+++ b/core/src/views/UnsupportedBrowser.vue
@@ -36,8 +36,8 @@ import { agents } from 'caniuse-lite/dist/unpacker/agents.js'
import { generateUrl, getRootUrl } from '@nextcloud/router'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import Web from 'vue-material-design-icons/Web.vue'
import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js'