aboutsummaryrefslogtreecommitdiffstats
path: root/core/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/views')
-rw-r--r--core/src/views/AccountMenu.vue247
-rw-r--r--core/src/views/ContactsMenu.vue233
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue848
-rw-r--r--core/src/views/Login.vue233
-rw-r--r--core/src/views/Profile.vue587
-rw-r--r--core/src/views/PublicPageMenu.vue131
-rw-r--r--core/src/views/PublicPageUserMenu.vue138
-rw-r--r--core/src/views/Setup.cy.ts369
-rw-r--r--core/src/views/Setup.vue460
-rw-r--r--core/src/views/UnifiedSearch.vue861
-rw-r--r--core/src/views/UnsupportedBrowser.vue187
11 files changed, 2854 insertions, 1440 deletions
diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue
new file mode 100644
index 00000000000..5b7ead636bd
--- /dev/null
+++ b/core/src/views/AccountMenu.vue
@@ -0,0 +1,247 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="user-menu"
+ class="account-menu"
+ is-nav
+ :aria-label="t('core', 'Settings menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
+ <NcAvatar :key="String(showUserStatus)"
+ class="account-menu__avatar"
+ disable-menu
+ disable-tooltip
+ :show-user-status="showUserStatus"
+ :user="currentUserId"
+ :preloaded-user-status="userStatus" />
+ </template>
+ <ul class="account-menu__list">
+ <AccountMenuProfileEntry :id="profileEntry.id"
+ :name="profileEntry.name"
+ :href="profileEntry.href"
+ :active="profileEntry.active" />
+ <AccountMenuEntry v-for="entry in otherEntries"
+ :id="entry.id"
+ :key="entry.id"
+ :name="entry.name"
+ :href="entry.href"
+ :active="entry.active"
+ :icon="entry.icon" />
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import { getCurrentUser } from '@nextcloud/auth'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { defineComponent } from 'vue'
+import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
+
+import axios from '@nextcloud/axios'
+import logger from '../logger.js'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+interface ISettingsNavigationEntry {
+ /**
+ * id of the entry, used as HTML ID, for example, "settings"
+ */
+ id: string
+ /**
+ * Label of the entry, for example, "Personal Settings"
+ */
+ name: string
+ /**
+ * Icon of the entry, for example, "/apps/settings/img/personal.svg"
+ */
+ icon: string
+ /**
+ * Type of the entry
+ */
+ type: 'settings'|'link'|'guest'
+ /**
+ * Link of the entry, for example, "/settings/user"
+ */
+ href: string
+ /**
+ * Whether the entry is active
+ */
+ active: boolean
+ /**
+ * Order of the entry
+ */
+ order: number
+ /**
+ * Number of unread pf this items
+ */
+ unread: number
+ /**
+ * Classes for custom styling
+ */
+ classes: string
+}
+
+const USER_DEFINABLE_STATUSES = getAllStatusOptions()
+
+export default defineComponent({
+ name: 'AccountMenu',
+
+ components: {
+ AccountMenuEntry,
+ AccountMenuProfileEntry,
+ NcAvatar,
+ NcHeaderMenu,
+ },
+
+ setup() {
+ const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {})
+ const { profile: profileEntry, ...otherEntries } = settingsNavEntries
+
+ return {
+ currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid,
+ currentUserId: getCurrentUser()!.uid,
+
+ profileEntry,
+ otherEntries,
+
+ t,
+ }
+ },
+
+ data() {
+ return {
+ showUserStatus: false,
+ userStatus: {
+ status: null,
+ icon: null,
+ message: null,
+ },
+ }
+ },
+
+ computed: {
+ translatedUserStatus() {
+ return {
+ ...this.userStatus,
+ status: this.translateStatus(this.userStatus.status),
+ }
+ },
+
+ avatarDescription() {
+ const description = [
+ t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }),
+ ...Object.values(this.translatedUserStatus).filter(Boolean),
+ ].join(' — ')
+ return description
+ },
+ },
+
+ async created() {
+ if (!getCapabilities()?.user_status?.enabled) {
+ return
+ }
+
+ const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
+ try {
+ const response = await axios.get(url)
+ const { status, icon, message } = response.data.ocs.data
+ this.userStatus = { status, icon, message }
+ } catch (e) {
+ logger.error('Failed to load user status')
+ }
+ this.showUserStatus = true
+ },
+
+ mounted() {
+ subscribe('user_status:status.updated', this.handleUserStatusUpdated)
+ emit('core:user-menu:mounted')
+ },
+
+ methods: {
+ handleUserStatusUpdated(state) {
+ if (this.currentUserId === state.userId) {
+ this.userStatus = {
+ status: state.status,
+ icon: state.icon,
+ message: state.message,
+ }
+ }
+ },
+
+ translateStatus(status) {
+ const statusMap = Object.fromEntries(
+ USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]),
+ )
+ if (statusMap[status]) {
+ return statusMap[status]
+ }
+ return status
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(#header-menu-user-menu) {
+ padding: 0 !important;
+}
+
+.account-menu {
+ &__avatar {
+ --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%);
+ outline: var(--account-menu-outline);
+ position: fixed;
+ // do not apply the alpha mask on the avatar div
+ mask: none !important;
+
+ &:hover {
+ --account-menu-outline: none;
+ // Add hover styles similar to the focus-visible style
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ padding-inline: 0 var(--default-grid-baseline);
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ :deep(button) {
+ // Normally header menus are slightly translucent when not active
+ // this is generally ok but for the avatar this is weird so fix the opacity
+ opacity: 1 !important;
+
+ // The avatar is just the "icon" of the button
+ // So we add the focus-visible manually
+ &:focus-visible {
+ .account-menu__avatar {
+ --account-menu-outline: none;
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+ }
+}
+</style>
diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue
new file mode 100644
index 00000000000..924ddcea56b
--- /dev/null
+++ b/core/src/views/ContactsMenu.vue
@@ -0,0 +1,233 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcHeaderMenu id="contactsmenu"
+ class="contactsmenu"
+ :aria-label="t('core', 'Search contacts')"
+ @open="handleOpen">
+ <template #trigger>
+ <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
+ </template>
+ <div class="contactsmenu__menu">
+ <div class="contactsmenu__menu__input-wrapper">
+ <NcTextField id="contactsmenu__menu__search"
+ ref="contactsMenuInput"
+ :value.sync="searchTerm"
+ trailing-button-icon="close"
+ :label="t('core', 'Search contacts')"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="searchTerm !== ''"
+ :placeholder="t('core', 'Search contacts …')"
+ class="contactsmenu__menu__search"
+ @input="onInputDebounced"
+ @trailing-button-click="onReset" />
+ </div>
+ <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="loadingText" :name="loadingText">
+ <template #icon>
+ <NcLoadingIcon />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <div v-else class="contactsmenu__menu__content">
+ <div id="contactsmenu-contacts">
+ <ul>
+ <Contact v-for="contact in contacts" :key="contact.id" :contact="contact" />
+ </ul>
+ </div>
+ <div v-if="contactsAppEnabled" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppURL">
+ {{ t('core', 'Show all contacts') }}
+ </NcButton>
+ </div>
+ <div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppMgmtURL">
+ {{ t('core', 'Install the Contacts app') }}
+ </NcButton>
+ </div>
+ </div>
+ </div>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import { mdiContacts, mdiMagnify } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Contact from '../components/ContactsMenu/Contact.vue'
+import logger from '../logger.js'
+import Nextcloud from '../mixins/Nextcloud.js'
+
+export default {
+ name: 'ContactsMenu',
+
+ components: {
+ Contact,
+ NcButton,
+ NcEmptyContent,
+ NcHeaderMenu,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ NcTextField,
+ },
+
+ mixins: [Nextcloud],
+
+ setup() {
+ return {
+ mdiContacts,
+ mdiMagnify,
+ }
+ },
+
+ data() {
+ const user = getCurrentUser()
+ return {
+ contactsAppEnabled: false,
+ contactsAppURL: generateUrl('/apps/contacts'),
+ contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'),
+ canInstallApp: user.isAdmin,
+ contacts: [],
+ loadingText: undefined,
+ error: false,
+ searchTerm: '',
+ }
+ },
+
+ methods: {
+ async handleOpen() {
+ await this.getContacts('')
+ },
+ async getContacts(searchTerm) {
+ if (searchTerm === '') {
+ this.loadingText = t('core', 'Loading your contacts …')
+ } else {
+ this.loadingText = t('core', 'Looking for {term} …', {
+ term: searchTerm,
+ })
+ }
+
+ // Let the user try a different query if the previous one failed
+ this.error = false
+
+ try {
+ const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ this.contacts = contacts
+ this.contactsAppEnabled = contactsAppEnabled
+ this.loadingText = undefined
+ } catch (error) {
+ logger.error('could not load contacts', {
+ error,
+ searchTerm,
+ })
+ this.error = true
+ }
+ },
+ onInputDebounced: debounce(function() {
+ this.getContacts(this.searchTerm)
+ }, 500),
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ this.searchTerm = ''
+ this.contacts = []
+ this.focusInput()
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.contactsMenuInput.focus()
+ this.$refs.contactsMenuInput.select()
+ })
+ },
+
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.contactsmenu {
+ overflow-y: hidden;
+
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__menu {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: calc(50px * 6 + 2px + 26px);
+ max-height: inherit;
+
+ label[for="contactsmenu__menu__search"] {
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
+ }
+
+ &__input-wrapper {
+ padding: 10px;
+ z-index: 2;
+ top: 0;
+ }
+
+ &__search {
+ width: 100%;
+ height: 34px;
+ margin-top: 0!important;
+ }
+
+ &__content {
+ overflow-y: auto;
+ margin-top: 10px;
+ flex: 1 1 auto;
+
+ &__footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+ }
+
+ a {
+ &:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--color-main-text) !important; // override rule in core/css/headers.scss #header a:focus-visible
+ }
+ }
+ }
+
+ :deep(.empty-content) {
+ margin: 0 !important;
+ }
+}
+</style>
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
new file mode 100644
index 00000000000..1277970ba0e
--- /dev/null
+++ b/core/src/views/LegacyUnifiedSearch.vue
@@ -0,0 +1,848 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="unified-search"
+ class="unified-search"
+ :exclude-click-outside-selectors="['.popover']"
+ :open.sync="open"
+ :aria-label="ariaLabel"
+ @open="onOpen"
+ @close="onClose">
+ <!-- Header icon -->
+ <template #trigger>
+ <Magnify class="unified-search__trigger-icon" :size="20" />
+ </template>
+
+ <!-- Search form & filters wrapper -->
+ <div class="unified-search__input-wrapper">
+ <div class="unified-search__input-row">
+ <NcTextField ref="input"
+ :value.sync="query"
+ trailing-button-icon="close"
+ :label="ariaLabel"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="query !== ''"
+ aria-describedby="unified-search-desc"
+ class="unified-search__form-input"
+ :class="{'unified-search__form-input--with-reset': !!query}"
+ :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
+ @trailing-button-click="onReset"
+ @input="onInputDebounced" />
+ <p id="unified-search-desc" class="hidden-visually">
+ {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
+ </p>
+
+ <!-- Search filters -->
+ <NcActions v-if="availableFilters.length > 1"
+ class="unified-search__filters"
+ placement="bottom-end"
+ container=".unified-search__input-wrapper">
+ <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
+ <NcActionButton v-for="filter in availableFilters"
+ :key="filter"
+ icon="icon-filter"
+ @click.stop="onClickFilter(`in:${filter}`)">
+ {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+
+ <template v-if="!hasResults">
+ <!-- Loading placeholders -->
+ <SearchResultPlaceholders v-if="isLoading" />
+
+ <NcEmptyContent v-else-if="isValidQuery"
+ :title="validQueryTitle">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else-if="!isLoading || isShortQuery"
+ :title="t('core', 'Start typing to search')"
+ :description="shortQueryDescription">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+ </template>
+
+ <!-- Grouped search results -->
+ <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
+ <h2 :key="type" class="unified-search__results-header">
+ {{ typesMap[type] }}
+ </h2>
+ <ul :key="type"
+ class="unified-search__results"
+ :class="`unified-search__results-${type}`"
+ :aria-label="typesMap[type]">
+ <!-- Search results -->
+ <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+ <SearchResult v-bind="result"
+ :query="query"
+ :focused="focused === 0 && typesIndex === 0 && index === 0"
+ @focus="setFocusedIndex" />
+ </li>
+
+ <!-- Load more button -->
+ <li>
+ <SearchResult v-if="!reached[type]"
+ class="unified-search__result-more"
+ :title="loading[type]
+ ? t('core', 'Loading more results …')
+ : t('core', 'Load more results')"
+ :icon-class="loading[type] ? 'icon-loading-small' : ''"
+ @click.prevent.stop="loadMore(type)"
+ @focus="setFocusedIndex" />
+ </li>
+ </ul>
+ </template>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import debounce from 'debounce'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
+
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+ name: 'LegacyUnifiedSearch',
+
+ components: {
+ Magnify,
+ NcActionButton,
+ NcActions,
+ NcEmptyContent,
+ NcHeaderMenu,
+ SearchResult,
+ SearchResultPlaceholders,
+ NcTextField,
+ },
+
+ data() {
+ return {
+ types: [],
+
+ // Cursors per types
+ cursors: {},
+ // Various search limits per types
+ limits: {},
+ // Loading types
+ loading: {},
+ // Reached search types
+ reached: {},
+ // Pending cancellable requests
+ requests: [],
+ // List of all results
+ results: {},
+
+ query: '',
+ focused: null,
+ triggered: false,
+
+ defaultLimit,
+ minSearchLength,
+ enableLiveSearch,
+
+ open: false,
+ }
+ },
+
+ computed: {
+ typesIDs() {
+ return this.types.map(type => type.id)
+ },
+ typesNames() {
+ return this.types.map(type => type.name)
+ },
+ typesMap() {
+ return this.types.reduce((prev, curr) => {
+ prev[curr.id] = curr.name
+ return prev
+ }, {})
+ },
+
+ ariaLabel() {
+ return t('core', 'Search')
+ },
+
+ /**
+ * Is there any result to display
+ *
+ * @return {boolean}
+ */
+ hasResults() {
+ return Object.keys(this.results).length !== 0
+ },
+
+ /**
+ * Return ordered results
+ *
+ * @return {Array}
+ */
+ orderedResults() {
+ return this.typesIDs
+ .filter(type => type in this.results)
+ .map(type => ({
+ type,
+ list: this.results[type],
+ }))
+ },
+
+ /**
+ * Available filters
+ * We only show filters that are available on the results
+ *
+ * @return {string[]}
+ */
+ availableFilters() {
+ return Object.keys(this.results)
+ },
+
+ /**
+ * Applied filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersIn() {
+ let match
+ const filters = []
+ while ((match = regexFilterIn.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Applied anti filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersNot() {
+ let match
+ const filters = []
+ while ((match = regexFilterNot.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Valid query empty content title
+ *
+ * @return {string}
+ */
+ validQueryTitle() {
+ return this.triggered
+ ? t('core', 'No results for {query}', { query: this.query })
+ : t('core', 'Press Enter to start searching')
+ },
+
+ /**
+ * Short query empty content description
+ *
+ * @return {string}
+ */
+ shortQueryDescription() {
+ if (!this.isShortQuery) {
+ return ''
+ }
+
+ return n('core',
+ 'Please enter {minSearchLength} character or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
+ this.minSearchLength,
+ { minSearchLength: this.minSearchLength })
+ },
+
+ /**
+ * Is the current search too short
+ *
+ * @return {boolean}
+ */
+ isShortQuery() {
+ return this.query && this.query.trim().length < minSearchLength
+ },
+
+ /**
+ * Is the current search valid
+ *
+ * @return {boolean}
+ */
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && !this.isShortQuery
+ },
+
+ /**
+ * Have we reached the end of all types searches
+ *
+ * @return {boolean}
+ */
+ isDoneSearching() {
+ return Object.values(this.reached).every(state => state === false)
+ },
+
+ /**
+ * Is there any search in progress
+ *
+ * @return {boolean}
+ */
+ isLoading() {
+ return Object.values(this.loading).some(state => state === true)
+ },
+ },
+
+ async created() {
+ this.types = await getTypes()
+ this.logger.debug('Unified Search initialized with the following providers', this.types)
+ },
+
+ beforeDestroy() {
+ unsubscribe('files:navigation:changed', this.onNavigationChange)
+ },
+
+ mounted() {
+ // subscribe in mounted, as onNavigationChange relys on $el
+ subscribe('files:navigation:changed', this.onNavigationChange)
+
+ if (OCP.Accessibility.disableKeyboardShortcuts()) {
+ return
+ }
+
+ document.addEventListener('keydown', (event) => {
+ // if not already opened, allows us to trigger default browser on second keydown
+ if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
+ event.preventDefault()
+ this.open = true
+ } else if (event.ctrlKey && event.key === 'f' && this.open) {
+ // User wants to use the native browser search, so we close ours again
+ this.open = false
+ }
+
+ // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+ if (this.open) {
+ // If arrow down, focus next result
+ if (event.key === 'ArrowDown') {
+ this.focusNext(event)
+ }
+
+ // If arrow up, focus prev result
+ if (event.key === 'ArrowUp') {
+ this.focusPrev(event)
+ }
+ }
+ })
+ },
+
+ methods: {
+ async onOpen() {
+ // Update types list in the background
+ this.types = await getTypes()
+ },
+ onClose() {
+ emit('nextcloud:unified-search.close')
+ },
+
+ onNavigationChange() {
+ this.$el?.querySelector?.('form[role="search"]')?.reset?.()
+ },
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ emit('nextcloud:unified-search.reset')
+ this.logger.debug('Search reset')
+ this.query = ''
+ this.resetState()
+ this.focusInput()
+ },
+ async resetState() {
+ this.cursors = {}
+ this.limits = {}
+ this.reached = {}
+ this.results = {}
+ this.focused = null
+ this.triggered = false
+ await this.cancelPendingRequests()
+ },
+
+ /**
+ * Cancel any ongoing searches
+ */
+ async cancelPendingRequests() {
+ // Cloning so we can keep processing other requests
+ const requests = this.requests.slice(0)
+ this.requests = []
+
+ // Cancel all pending requests
+ await Promise.all(requests.map(cancel => cancel()))
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ this.$refs.input.select()
+ })
+ },
+
+ /**
+ * If we have results already, open first one
+ * If not, trigger the search again
+ */
+ onInputEnter() {
+ if (this.hasResults) {
+ const results = this.getResultsList()
+ results[0].click()
+ return
+ }
+ this.onInput()
+ },
+
+ /**
+ * Start searching on input
+ */
+ async onInput() {
+ // emit the search query
+ emit('nextcloud:unified-search.search', { query: this.query })
+
+ // Do not search if not long enough
+ if (this.query.trim() === '' || this.isShortQuery) {
+ for (const type of this.typesIDs) {
+ this.$delete(this.results, type)
+ }
+ return
+ }
+
+ let types = this.typesIDs
+ let query = this.query
+
+ // Filter out types
+ if (this.usedFiltersNot.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+ }
+
+ // Only use those filters if any and check if they are valid
+ if (this.usedFiltersIn.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+ }
+
+ // Remove any filters from the query
+ query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+ // Reset search if the query changed
+ await this.resetState()
+ this.triggered = true
+
+ if (!types.length) {
+ // no results since no types were selected
+ this.logger.error('No types to search in')
+ return
+ }
+
+ this.$set(this.loading, 'all', true)
+ this.logger.debug(`Searching ${query} in`, types)
+
+ Promise.all(types.map(async type => {
+ try {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.$set(this.results, type, data.ocs.data.entries)
+ } else {
+ this.$delete(this.results, type)
+ }
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ } else if (!data.ocs.data.isPaginated) {
+ // If no cursor and no pagination, we save the default amount
+ // provided by server's initial state `defaultLimit`
+ this.$set(this.limits, type, this.defaultLimit)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+
+ // If none already focused, focus the first rendered result
+ if (this.focused === null) {
+ this.focused = 0
+ }
+ return REQUEST_OK
+ } catch (error) {
+ this.$delete(this.results, type)
+
+ // If this is not a cancelled throw
+ if (error.response && error.response.status) {
+ this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+ showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+ return REQUEST_FAILED
+ }
+ return REQUEST_CANCELED
+ }
+ })).then(results => {
+ // Do not declare loading finished if the request have been cancelled
+ // This means another search was triggered and we're therefore still loading
+ if (results.some(result => result === REQUEST_CANCELED)) {
+ return
+ }
+ // We finished all searches
+ this.loading = {}
+ })
+ },
+ onInputDebounced: enableLiveSearch
+ ? debounce(function(e) {
+ this.onInput(e)
+ }, 500)
+ : function() {
+ this.triggered = false
+ },
+
+ /**
+ * Load more results for the provided type
+ *
+ * @param {string} type type
+ */
+ async loadMore(type) {
+ // If already loading, ignore
+ if (this.loading[type]) {
+ return
+ }
+
+ if (this.cursors[type]) {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ }
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.results[type].push(...data.ocs.data.entries)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+ } else {
+ // If no cursor, we might have all the results already,
+ // let's fake pagination and show the next xxx entries
+ if (this.limits[type] && this.limits[type] >= 0) {
+ this.limits[type] += this.defaultLimit
+
+ // Check if we reached end of pagination
+ if (this.limits[type] >= this.results[type].length) {
+ this.$set(this.reached, type, true)
+ }
+ }
+ }
+
+ // Focus result after render
+ if (this.focused !== null) {
+ this.$nextTick(() => {
+ this.focusIndex(this.focused)
+ })
+ }
+ },
+
+ /**
+ * Return a subset of the array if the search provider
+ * doesn't supports pagination
+ *
+ * @param {Array} list the results
+ * @param {string} type the type
+ * @return {Array}
+ */
+ limitIfAny(list, type) {
+ if (type in this.limits) {
+ return list.slice(0, this.limits[type])
+ }
+ return list
+ },
+
+ getResultsList() {
+ return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+ },
+
+ /**
+ * Focus the first result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusFirst(event) {
+ const results = this.getResultsList()
+ if (results && results.length > 0) {
+ if (event) {
+ event.preventDefault()
+ }
+ this.focused = 0
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the next result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusNext(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the last, focus the next one
+ if (results && results.length > 0 && this.focused + 1 < results.length) {
+ event.preventDefault()
+ this.focused++
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the previous result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusPrev(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the first, focus the previous one
+ if (results && results.length > 0 && this.focused > 0) {
+ event.preventDefault()
+ this.focused--
+ this.focusIndex(this.focused)
+ }
+
+ },
+
+ /**
+ * Focus the specified result index if it exists
+ *
+ * @param {number} index the result index
+ */
+ focusIndex(index) {
+ const results = this.getResultsList()
+ if (results && results[index]) {
+ results[index].focus()
+ }
+ },
+
+ /**
+ * Set the current focused element based on the target
+ *
+ * @param {Event} event the focus event
+ */
+ setFocusedIndex(event) {
+ const entry = event.target
+ const results = this.getResultsList()
+ const index = [...results].findIndex(search => search === entry)
+ if (index > -1) {
+ // let's not use focusIndex as the entry is already focused
+ this.focused = index
+ }
+ },
+
+ onClickFilter(filter) {
+ this.query = `${this.query} ${filter}`
+ .replace(/ {2}/g, ' ')
+ .trim()
+ this.onInput()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 10px;
+
+.unified-search {
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__input-wrapper {
+ position: sticky;
+ // above search results
+ z-index: 2;
+ top: 0;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ background-color: var(--color-main-background);
+
+ label[for="unified-search__input"] {
+ align-self: flex-start;
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
+ }
+ }
+
+ &__input-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ }
+
+ &__filters {
+ margin-block: $margin;
+ margin-inline: math.div($margin, 2) 0;
+ padding-top: 5px;
+ ul {
+ display: inline-flex;
+ justify-content: space-between;
+ }
+ }
+
+ &__form {
+ position: relative;
+ width: 100%;
+ margin: $margin 0;
+
+ // Loading spinner
+ &::after {
+ inset-inline-start: auto $input-padding;
+ }
+
+ &-input,
+ &-reset {
+ margin: math.div($input-padding, 2);
+ }
+
+ &-input {
+ width: 100%;
+ height: $input-height;
+ padding: $input-padding;
+
+ &:focus,
+ &:focus-visible,
+ &:active {
+ border-color: 2px solid var(--color-main-text) !important;
+ box-shadow: 0 0 0 2px var(--color-main-background) !important;
+ }
+
+ &,
+ &[placeholder],
+ &::placeholder {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // Hide webkit clear search
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ }
+ }
+
+ &-reset,
+ &-submit {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 4px;
+ width: $input-height - $input-padding;
+ height: $input-height - $input-padding;
+ min-height: 30px;
+ padding: 0;
+ opacity: .5;
+ border: none;
+ background-color: transparent;
+ margin-inline-end: 0;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+
+ &-submit {
+ inset-inline-end: 28px;
+ }
+ }
+
+ &__results {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ &-header {
+ display: block;
+ margin: $margin;
+ margin-bottom: $margin - 4px;
+ margin-inline-start: 13px;
+ color: var(--color-primary-element);
+ font-size: 19px;
+ font-weight: bold;
+ }
+ }
+
+ :deep(.unified-search__result-more) {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .empty-content {
+ margin: 10vh 0;
+
+ :deep(.empty-content__title) {
+ font-weight: normal;
+ font-size: var(--default-font-size);
+ text-align: center;
+ }
+ }
+}
+
+</style>
diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue
index e2ca484126c..a6fe8442779 100644
--- a/core/src/views/Login.vue
+++ b/core/src/views/Login.vue
@@ -1,127 +1,115 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div v-if="!hideLoginForm || directLogin">
- <transition name="fade" mode="out-in">
- <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"
- key="login">
- <LoginForm :username.sync="user"
- :redirect-url="redirectUrl"
- :direct-login="directLogin"
- :messages="messages"
- :errors="errors"
- :throttle-delay="throttleDelay"
- :auto-complete-allowed="autoCompleteAllowed"
- @submit="loading = true" />
- <a v-if="canResetPassword && resetPasswordLink !== ''"
- id="lost-password"
- :href="resetPasswordLink">
- {{ t('core', 'Forgot password?') }}
- </a>
- <a v-else-if="canResetPassword && !resetPassword"
- id="lost-password"
- :href="resetPasswordLink"
- @click.prevent="resetPassword = true">
- {{ t('core', 'Forgot password?') }}
- </a>
- <br>
- <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="#"
+ <div class="guest-box login-box">
+ <template v-if="!hideLoginForm || directLogin">
+ <transition name="fade" mode="out-in">
+ <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper">
+ <LoginForm :username.sync="user"
+ :redirect-url="redirectUrl"
+ :direct-login="directLogin"
+ :messages="messages"
+ :errors="errors"
+ :throttle-delay="throttleDelay"
+ :auto-complete-allowed="autoCompleteAllowed"
+ :email-states="emailStates"
+ @submit="loading = true" />
+ <NcButton v-if="hasPasswordless"
+ type="tertiary"
+ wide
@click.prevent="passwordlessLogin = true">
{{ t('core', 'Log in with a device') }}
- </a>
- </template>
- </div>
- <div v-else-if="!loading && passwordlessLogin"
- key="reset"
- class="login-additional">
- <PasswordLessLoginForm :username.sync="user"
- :redirect-url="redirectUrl"
- :auto-complete-allowed="autoCompleteAllowed"
- :is-https="isHttps"
- :is-localhost="isLocalhost"
- :has-public-key-credential="hasPublicKeyCredential"
- @submit="loading = true" />
- <a href="#" @click.prevent="passwordlessLogin = false">
- {{ t('core', 'Back') }}
- </a>
- </div>
- <div v-else-if="!loading && canResetPassword"
- key="reset"
- class="login-additional">
- <div class="lost-password-container">
- <ResetPassword v-if="resetPassword"
- :username.sync="user"
- :reset-password-link="resetPasswordLink"
- @abort="resetPassword = false" />
+ </NcButton>
+ <NcButton v-if="canResetPassword && resetPasswordLink !== ''"
+ id="lost-password"
+ :href="resetPasswordLink"
+ type="tertiary-no-background"
+ wide>
+ {{ t('core', 'Forgot password?') }}
+ </NcButton>
+ <NcButton v-else-if="canResetPassword && !resetPassword"
+ id="lost-password"
+ type="tertiary"
+ wide
+ @click.prevent="resetPassword = true">
+ {{ t('core', 'Forgot password?') }}
+ </NcButton>
</div>
- </div>
- <div v-else-if="resetPasswordTarget !== ''">
- <UpdatePassword :username.sync="user"
- :reset-password-target="resetPasswordTarget"
- @done="passwordResetFinished" />
- </div>
- </transition>
- </div>
- <div v-else>
- <transition name="fade" mode="out-in">
- <div class="warning">
- {{ t('core', 'Login form is disabled.') }}<br>
- <small>{{ t('core', 'Please contact your administrator.') }}
- </small>
- </div>
- </transition>
+ <div v-else-if="!loading && passwordlessLogin"
+ key="reset-pw-less"
+ class="login-additional login-box__wrapper">
+ <PasswordLessLoginForm :username.sync="user"
+ :redirect-url="redirectUrl"
+ :auto-complete-allowed="autoCompleteAllowed"
+ :is-https="isHttps"
+ :is-localhost="isLocalhost"
+ @submit="loading = true" />
+ <NcButton type="tertiary"
+ :aria-label="t('core', 'Back to login form')"
+ :wide="true"
+ @click="passwordlessLogin = false">
+ {{ t('core', 'Back') }}
+ </NcButton>
+ </div>
+ <div v-else-if="!loading && canResetPassword"
+ key="reset-can-reset"
+ class="login-additional">
+ <div class="lost-password-container">
+ <ResetPassword v-if="resetPassword"
+ :username.sync="user"
+ :reset-password-link="resetPasswordLink"
+ @abort="resetPassword = false" />
+ </div>
+ </div>
+ <div v-else-if="resetPasswordTarget !== ''">
+ <UpdatePassword :username.sync="user"
+ :reset-password-target="resetPasswordTarget"
+ @done="passwordResetFinished" />
+ </div>
+ </transition>
+ </template>
+ <template v-else>
+ <transition name="fade" mode="out-in">
+ <NcNoteCard type="info" :title="t('core', 'Login form is disabled.')">
+ {{ t('core', 'The Nextcloud login form is disabled. Use another login option if available or contact your administration.') }}
+ </NcNoteCard>
+ </transition>
+ </template>
+
+ <div id="alternative-logins" class="login-box__alternative-logins">
+ <NcButton v-for="(alternativeLogin, index) in alternativeLogins"
+ :key="index"
+ type="secondary"
+ :wide="true"
+ :class="[alternativeLogin.class]"
+ role="link"
+ :href="alternativeLogin.href">
+ {{ alternativeLogin.name }}
+ </NcButton>
+ </div>
</div>
</template>
<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/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 {
@@ -132,6 +120,8 @@ export default {
PasswordLessLoginForm,
ResetPassword,
UpdatePassword,
+ NcButton,
+ NcNoteCard,
},
data() {
@@ -154,27 +144,46 @@ export default {
directLogin: query.direct === '1',
hasPasswordless: loadState('core', 'webauthn-available', false),
countAlternativeLogins: loadState('core', 'countAlternativeLogins', false),
+ alternativeLogins: loadState('core', 'alternativeLogins', []),
isHttps: window.location.protocol === 'https:',
isLocalhost: window.location.hostname === 'localhost',
- hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
hideLoginForm: loadState('core', 'hideLoginForm', false),
+ emailStates: loadState('core', 'emailStates', []),
}
},
methods: {
passwordResetFinished() {
- this.resetPasswordTarget = ''
- this.directLogin = true
+ window.location.href = generateUrl('login')
},
},
}
</script>
-<style>
- .fade-enter-active, .fade-leave-active {
- transition: opacity .3s;
+<style scoped lang="scss">
+.login-box {
+ // Same size as dashboard panels
+ width: 320px;
+ box-sizing: border-box;
+
+ &__wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: calc(2 * var(--default-grid-baseline));
}
- .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;
}
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity .3s;
+}
+
+.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
+ opacity: 0;
+}
</style>
diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue
deleted file mode 100644
index a8d6522b435..00000000000
--- a/core/src/views/Profile.vue
+++ /dev/null
@@ -1,587 +0,0 @@
-<!--
- - @copyright Copyright (c) 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<template>
- <div class="profile">
- <div class="profile__header">
- <div class="profile__header__container">
- <div class="profile__header__container__placeholder" />
- <h2 class="profile__header__container__displayname">
- {{ displayname || userId }}
- <a v-if="isCurrentUser"
- class="primary profile__header__container__edit-button"
- :href="settingsUrl">
- <PencilIcon class="pencil-icon"
- decorative
- title=""
- :size="16" />
- {{ t('core', 'Edit Profile') }}
- </a>
- </h2>
- <div v-if="status.icon || status.message"
- class="profile__header__container__status-text"
- :class="{ interactive: isCurrentUser }"
- @click.prevent.stop="openStatusModal">
- {{ status.icon }} {{ status.message }}
- </div>
- </div>
- </div>
-
- <div class="profile__content">
- <div class="profile__sidebar">
- <Avatar class="avatar"
- :class="{ interactive: isCurrentUser }"
- :user="userId"
- :size="180"
- :show-user-status="true"
- :show-user-status-compact="false"
- :disable-menu="true"
- :disable-tooltip="true"
- :is-no-user="!isUserAvatarVisible"
- @click.native.prevent.stop="openStatusModal" />
-
- <div class="user-actions">
- <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action -->
- <PrimaryActionButton v-if="primaryAction"
- class="user-actions__primary"
- :href="primaryAction.target"
- :icon="primaryAction.icon"
- :target="primaryAction.id === 'phone' ? '_self' :'_blank'">
- {{ primaryAction.title }}
- </PrimaryActionButton>
- <div class="user-actions__other">
- <!-- FIXME Remove inline styles after https://github.com/nextcloud/nextcloud-vue/issues/2315 is fixed -->
- <Actions v-for="action in middleActions"
- :key="action.id"
- :default-icon="action.icon"
- style="
- background-position: 14px center;
- background-size: 16px;
- background-repeat: no-repeat;"
- :style="{
- backgroundImage: `url(${action.icon})`,
- ...(colorMainBackground === '#181818' && { filter: 'invert(1)' })
- }">
- <ActionLink :close-after-click="true"
- :icon="action.icon"
- :href="action.target"
- :target="action.id === 'phone' ? '_self' :'_blank'">
- {{ action.title }}
- </ActionLink>
- </Actions>
- <template v-if="otherActions">
- <Actions :force-menu="true">
- <ActionLink v-for="action in otherActions"
- :key="action.id"
- :class="{ 'icon-invert': colorMainBackground === '#181818' }"
- :close-after-click="true"
- :icon="action.icon"
- :href="action.target"
- :target="action.id === 'phone' ? '_self' :'_blank'">
- {{ action.title }}
- </ActionLink>
- </Actions>
- </template>
- </div>
- </div>
- </div>
-
- <div class="profile__blocks">
- <div v-if="organisation || role || address" class="profile__blocks-details">
- <div v-if="organisation || role" class="detail">
- <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p>
- </div>
- <div v-if="address" class="detail">
- <p>
- <MapMarkerIcon class="map-icon"
- decorative
- title=""
- :size="16" />
- {{ address }}
- </p>
- </div>
- </div>
- <template v-if="headline || biography">
- <div v-if="headline" class="profile__blocks-headline">
- <h3>{{ headline }}</h3>
- </div>
- <div v-if="biography" class="profile__blocks-biography">
- <p>{{ biography }}</p>
- </div>
- </template>
- <template v-else>
- <div class="profile__blocks-empty-info">
- <AccountIcon decorative
- title=""
- fill-color="var(--color-text-maxcontrast)"
- :size="60" />
- <h3>{{ emptyProfileMessage }}</h3>
- <p>{{ t('core', 'The headline and about sections will show up here') }}</p>
- </div>
- </template>
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import { getCurrentUser } from '@nextcloud/auth'
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { loadState } from '@nextcloud/initial-state'
-import { generateUrl } from '@nextcloud/router'
-import { showError } from '@nextcloud/dialogs'
-
-import Avatar from '@nextcloud/vue/dist/Components/Avatar'
-import Actions from '@nextcloud/vue/dist/Components/Actions'
-import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
-import MapMarkerIcon from 'vue-material-design-icons/MapMarker'
-import PencilIcon from 'vue-material-design-icons/Pencil'
-import AccountIcon from 'vue-material-design-icons/Account'
-
-import PrimaryActionButton from '../components/Profile/PrimaryActionButton'
-
-const status = loadState('core', 'status', {})
-const {
- userId,
- displayname,
- address,
- organisation,
- role,
- headline,
- biography,
- actions,
- isUserAvatarVisible,
-} = loadState('core', 'profileParameters', {
- userId: null,
- displayname: null,
- address: null,
- organisation: null,
- role: null,
- headline: null,
- biography: null,
- actions: [],
- isUserAvatarVisible: false,
-})
-
-export default {
- name: 'Profile',
-
- components: {
- AccountIcon,
- ActionLink,
- Actions,
- Avatar,
- MapMarkerIcon,
- PencilIcon,
- PrimaryActionButton,
- },
-
- data() {
- return {
- status,
- userId,
- displayname,
- address,
- organisation,
- role,
- headline,
- biography,
- actions,
- isUserAvatarVisible,
- }
- },
-
- computed: {
- isCurrentUser() {
- return getCurrentUser()?.uid === this.userId
- },
-
- allActions() {
- return this.actions
- },
-
- primaryAction() {
- if (this.allActions.length) {
- return this.allActions[0]
- }
- return null
- },
-
- middleActions() {
- if (this.allActions.slice(1, 4).length) {
- return this.allActions.slice(1, 4)
- }
- return null
- },
-
- otherActions() {
- if (this.allActions.slice(4).length) {
- return this.allActions.slice(4)
- }
- return null
- },
-
- settingsUrl() {
- return generateUrl('/settings/user')
- },
-
- colorMainBackground() {
- // For some reason the returned string has prepended whitespace
- return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim()
- },
-
- emptyProfileMessage() {
- return this.isCurrentUser
- ? t('core', 'You have not added any info yet')
- : t('core', '{user} has not added any info yet', { user: (this.displayname || this.userId) })
- },
- },
-
- mounted() {
- // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end
- document.title = `${this.displayname || this.userId} - ${document.title}`
- subscribe('user_status:status.updated', this.handleStatusUpdate)
- },
-
- beforeDestroy() {
- unsubscribe('user_status:status.updated', this.handleStatusUpdate)
- },
-
- methods: {
- handleStatusUpdate(status) {
- if (this.isCurrentUser && status.userId === this.userId) {
- this.status = status
- }
- },
-
- openStatusModal() {
- const statusMenuItem = document.querySelector('.user-status-menu-item__toggle')
- // Changing the user status is only enabled if you are the current user
- if (this.isCurrentUser) {
- if (statusMenuItem) {
- statusMenuItem.click()
- } else {
- showError(t('core', 'Error opening the user status modal, try hard refreshing the page'))
- }
- }
- },
- },
-}
-</script>
-
-<style lang="scss">
-// Override header styles
-#header {
- background-color: transparent !important;
- background-image: none !important;
-}
-
-#content {
- padding-top: 0px;
-}
-</style>
-
-<style lang="scss" scoped>
-$profile-max-width: 1024px;
-$content-max-width: 640px;
-
-.profile {
- width: 100%;
-
- &__header {
- position: sticky;
- height: 190px;
- top: -40px;
-
- &__container {
- align-self: flex-end;
- width: 100%;
- max-width: $profile-max-width;
- margin: 0 auto;
- display: grid;
- grid-template-rows: max-content max-content;
- grid-template-columns: 240px 1fr;
- justify-content: center;
-
- &__placeholder {
- grid-row: 1 / 3;
- }
-
- &__displayname, &__status-text {
- color: var(--color-primary-text);
- }
-
- &__displayname {
- width: $content-max-width;
- height: 45px;
- margin-top: 128px;
- // Override the global style declaration
- margin-bottom: 0;
- font-size: 30px;
- display: flex;
- align-items: center;
- cursor: text;
-
- &:not(:last-child) {
- margin-top: 100px;
- margin-bottom: 4px;
- }
- }
-
- &__edit-button {
- border: none;
- margin-left: 18px;
- margin-top: 2px;
- color: var(--color-primary-element);
- background-color: var(--color-primary-text);
- box-shadow: 0 0 0 2px var(--color-primary-text);
- border-radius: var(--border-radius-pill);
- padding: 0 18px;
- font-size: var(--default-font-size);
- height: 44px;
- line-height: 44px;
- font-weight: bold;
-
- &:hover,
- &:focus,
- &:active {
- color: var(--color-primary-text);
- background-color: var(--color-primary-element-light);
- }
-
- .pencil-icon {
- display: inline-block;
- vertical-align: middle;
- margin-top: 2px;
- }
- }
-
- &__status-text {
- width: max-content;
- max-width: $content-max-width;
- padding: 5px 10px;
- margin-left: -12px;
- margin-top: 2px;
-
- &.interactive {
- cursor: pointer;
-
- &:hover,
- &:focus,
- &:active {
- background-color: var(--color-main-background);
- color: var(--color-main-text);
- border-radius: var(--border-radius-pill);
- font-weight: bold;
- box-shadow: 0 3px 6px var(--color-box-shadow);
- }
- }
- }
- }
- }
-
- &__sidebar {
- position: sticky;
- top: var(--header-height);
- align-self: flex-start;
- padding-top: 20px;
- min-width: 220px;
- margin: -150px 20px 0 0;
-
- // Specificity hack is needed to override Avatar component styles
- &::v-deep .avatar.avatardiv, h2 {
- text-align: center;
- margin: auto;
- display: block;
- padding: 8px;
- }
-
- &::v-deep .avatar.avatardiv:not(.avatardiv--unknown) {
- background-color: var(--color-main-background) !important;
- box-shadow: none;
- }
-
- &::v-deep .avatar.avatardiv {
- .avatardiv__user-status {
- right: 14px;
- bottom: 14px;
- width: 34px;
- height: 34px;
- background-size: 28px;
- border: none;
- // Styles when custom status icon and status text are set
- background-color: var(--color-main-background);
- line-height: 34px;
- font-size: 20px;
- }
- }
-
- &::v-deep .avatar.interactive.avatardiv {
- .avatardiv__user-status {
- cursor: pointer;
-
- &:hover,
- &:focus,
- &:active {
- box-shadow: 0 3px 6px var(--color-box-shadow);
- }
- }
- }
- }
-
- &__content {
- max-width: $profile-max-width;
- margin: 0 auto;
- display: flex;
- width: 100%;
- }
-
- &__blocks {
- margin: 18px 0 80px 0;
- display: grid;
- gap: 16px 0;
- width: $content-max-width;
-
- p, h3 {
- overflow-wrap: anywhere;
- }
-
- &-details {
- display: flex;
- flex-direction: column;
- gap: 2px 0;
-
- .detail {
- display: inline-block;
- color: var(--color-text-maxcontrast);
-
- p .map-icon {
- display: inline-block;
- vertical-align: middle;
- }
- }
- }
-
- &-headline {
- margin-top: 10px;
-
- h3 {
- font-weight: bold;
- font-size: 20px;
- margin: 0;
- }
- }
-
- &-biography {
- white-space: pre-line;
- }
-
- h3, p {
- cursor: text;
- }
-
- &-empty-info {
- margin-top: 80px;
- margin-right: 100px;
- display: flex;
- flex-direction: column;
- text-align: center;
-
- h3 {
- font-weight: bold;
- font-size: 18px;
- margin: 8px 0;
- }
- }
- }
-}
-
-@media only screen and (max-width: 1024px) {
- .profile {
- &__header {
- height: 250px;
- position: unset;
-
- &__container {
- grid-template-columns: unset;
-
- &__displayname {
- margin: 100px 20px 0px;
- width: unset;
- display: unset;
- text-align: center;
- }
-
- &__edit-button {
- width: fit-content;
- display: block;
- margin: 30px auto;
- }
- }
- }
-
- &__content {
- display: block;
- }
-
- &__blocks {
- width: unset;
- max-width: 600px;
- margin: 0 auto;
- padding: 20px 50px 50px 50px;
-
- &-empty-info {
- margin: 0;
- }
- }
-
- &__sidebar {
- margin: unset;
- position: unset;
- }
- }
-}
-
-.user-actions {
- display: flex;
- flex-direction: column;
- gap: 8px 0;
- margin-top: 20px;
-
- &__primary {
- margin: 0 auto;
- }
-
- &__other {
- display: flex;
- justify-content: center;
- gap: 0 4px;
- }
-}
-
-.icon-invert {
- &::v-deep .action-link__icon {
- filter: invert(1);
- }
-}
-</style>
diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue
new file mode 100644
index 00000000000..a05f3a6b889
--- /dev/null
+++ b/core/src/views/PublicPageMenu.vue
@@ -0,0 +1,131 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <div class="public-page-menu__wrapper">
+ <NcButton v-if="primaryAction"
+ id="public-page-menu--primary"
+ class="public-page-menu__primary"
+ :href="primaryAction.href"
+ type="primary"
+ @click="openDialogIfNeeded">
+ <template v-if="primaryAction.icon" #icon>
+ <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" />
+ </template>
+ {{ primaryAction.label }}
+ </NcButton>
+
+ <NcHeaderMenu v-if="secondaryActions.length > 0"
+ id="public-page-menu"
+ :aria-label="t('core', 'More actions')"
+ :open.sync="showMenu">
+ <template #trigger>
+ <IconMore :size="20" />
+ </template>
+ <ul :aria-label="t('core', 'More actions')"
+ class="public-page-menu"
+ role="menu">
+ <component :is="getComponent(entry)"
+ v-for="entry, index in secondaryActions"
+ :key="index"
+ v-bind="entry"
+ @click="showMenu = false" />
+ </ul>
+ </NcHeaderMenu>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
+import { computed, ref, type Ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import IconMore from 'vue-material-design-icons/DotsHorizontal.vue'
+import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue'
+import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue'
+import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue'
+import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue'
+import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue'
+
+interface IPublicPageMenu {
+ id: string
+ label: string
+ href: string
+ icon?: string
+ html?: string
+ details?: string
+}
+
+const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu')
+
+/** used to conditionally close the menu when clicking entry */
+const showMenu = ref(false)
+
+const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>>
+/** The primary menu action - only showed when not on mobile */
+const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0])
+/** All other secondary actions (including primary action on mobile) */
+const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1))
+
+/**
+ * Get the render component for an entry
+ * @param entry The entry to get the component for
+ */
+function getComponent(entry: IPublicPageMenu) {
+ if ('html' in entry) {
+ return PublicPageMenuCustomEntry
+ }
+ switch (entry.id) {
+ case 'save':
+ return PublicPageMenuExternalEntry
+ case 'directLink':
+ return PublicPageMenuLinkEntry
+ default:
+ return PublicPageMenuEntry
+ }
+}
+
+/**
+ * Open the "federated share" dialog if needed
+ */
+function openDialogIfNeeded() {
+ if (primaryAction.value?.id !== 'save') {
+ return
+ }
+ spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label })
+}
+</script>
+
+<style scoped lang="scss">
+.public-page-menu {
+ box-sizing: border-box;
+
+ > :deep(*) {
+ box-sizing: border-box;
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: var(--default-grid-baseline);
+ }
+
+ &__primary {
+ height: var(--default-clickable-area);
+ margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2);
+
+ // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image))
+ &:focus-visible {
+ border-color: var(--color-background-plain-text) !important;
+ }
+ }
+
+ &__primary-icon {
+ filter: var(--primary-invert-if-bright);
+ }
+}
+</style>
diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue
new file mode 100644
index 00000000000..7bd6521e7aa
--- /dev/null
+++ b/core/src/views/PublicPageUserMenu.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcHeaderMenu id="public-page-user-menu"
+ class="public-page-user-menu"
+ is-nav
+ :aria-label="t('core', 'User menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <NcAvatar class="public-page-user-menu__avatar"
+ disable-menu
+ disable-tooltip
+ is-guest
+ :user="displayName || '?'" />
+ </template>
+
+ <!-- Privacy notice -->
+ <NcNoteCard class="public-page-user-menu__list-note"
+ :text="privacyNotice"
+ type="info" />
+
+ <ul class="public-page-user-menu__list">
+ <!-- Nickname dialog -->
+ <AccountMenuEntry id="set-nickname"
+ :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')"
+ href="#"
+ @click.prevent.stop="setNickname">
+ <template #icon>
+ <IconAccount />
+ </template>
+ </AccountMenuEntry>
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import type { NextcloudUser } from '@nextcloud/auth'
+
+import '@nextcloud/dialogs/style.css'
+import { defineComponent } from 'vue'
+import { getGuestUser } from '@nextcloud/auth'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import IconAccount from 'vue-material-design-icons/AccountOutline.vue'
+
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+export default defineComponent({
+ name: 'PublicPageUserMenu',
+ components: {
+ AccountMenuEntry,
+ IconAccount,
+ NcAvatar,
+ NcHeaderMenu,
+ NcNoteCard,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ displayName: getGuestUser().displayName,
+ }
+ },
+
+ computed: {
+ avatarDescription(): string {
+ return t('core', 'User menu')
+ },
+
+ privacyNotice(): string {
+ return this.displayName
+ ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName })
+ : t('core', 'You are currently not identified.')
+ },
+ },
+
+ mounted() {
+ subscribe('user:info:changed', (user: NextcloudUser) => {
+ this.displayName = user.displayName || ''
+ })
+ },
+
+ methods: {
+ setNickname() {
+ showGuestUserPrompt({
+ nickname: this.displayName,
+ cancellable: true,
+ })
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.public-page-user-menu {
+ &, * {
+ box-sizing: border-box;
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ &__list-note {
+ padding-block: 5px !important;
+ padding-inline: 5px !important;
+ max-width: 300px;
+ margin: 5px !important;
+ margin-bottom: 0 !important;
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ width: 100%;
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+}
+</style>
diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts
new file mode 100644
index 00000000000..f252801c4d8
--- /dev/null
+++ b/core/src/views/Setup.cy.ts
@@ -0,0 +1,369 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { SetupConfig, SetupLinks } from '../install'
+import SetupView from './Setup.vue'
+
+import '../../css/guest.css'
+
+const defaultConfig = Object.freeze({
+ adminlogin: '',
+ adminpass: '',
+ dbuser: '',
+ dbpass: '',
+ dbname: '',
+ dbtablespace: '',
+ dbhost: '',
+ dbtype: '',
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ },
+ directory: '',
+ hasAutoconfig: false,
+ htaccessWorking: true,
+ serverRoot: '/var/www/html',
+ errors: [],
+}) as SetupConfig
+
+const links = {
+ adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
+ adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
+ adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
+} as SetupLinks
+
+describe('Default setup page', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders default config', () => {
+ cy.mockInitialState('core', 'config', defaultConfig)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form]').scrollIntoView()
+ cy.get('[data-cy-setup-form]').should('be.visible')
+
+ // Single note is the footer help
+ cy.get('[data-cy-setup-form-note]')
+ .should('have.length', 1)
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation')
+
+ // DB radio selectors
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('exist')
+ .find('input')
+ .should('be.checked')
+
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist')
+
+ // Sqlite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+
+ // admin login, password, data directory and 3 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 6)
+ })
+
+ it('Renders single DB sqlite', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only sqlite
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Two warnings: sqlite and single db support
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+
+ // Admin login, password and data directory
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 3)
+ })
+
+ it('Renders single DB mysql', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ mysql: 'MySQL/MariaDB',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only mysql
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Single db support warning
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+ .invoke('html')
+ .should('contains', links.adminSourceInstall)
+
+ // No SQLite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('not.exist')
+
+ // Admin login, password, data directory, db user,
+ // db password, db name and db host
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+ })
+
+ it('Changes fields from sqlite to mysql then oci', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ oci: 'Oracle',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // SQLite selected
+ cy.get('[data-cy-setup-form-field="dbtype-sqlite"]')
+ .should('be.visible')
+ .find('input')
+ .should('be.checked')
+
+ // Admin login, password, data directory and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+
+ // Change to MySQL
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 11)
+
+ // Change to Oracle
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db table space, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 12)
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('be.visible')
+ })
+})
+
+describe('Setup page with errors and warning', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders error from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders errors from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ 'Error message 1',
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 2)
+ cy.get('[data-cy-setup-form-note="error"]').eq(0)
+ .should('contain', 'Error message 1')
+ cy.get('[data-cy-setup-form-note="error"]').eq(1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders all the submitted fields on error', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('input[data-cy-setup-form-field="adminlogin"]')
+ .should('have.value', 'admin')
+ cy.get('input[data-cy-setup-form-field="adminpass"]')
+ .should('have.value', 'password')
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input')
+ .should('be.checked')
+ cy.get('input[data-cy-setup-form-field="dbname"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbuser"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbpass"]')
+ .should('have.value', 'password')
+ cy.get('input[data-cy-setup-form-field="dbhost"]')
+ .should('have.value', 'localhost')
+ cy.get('input[data-cy-setup-form-field="directory"]')
+ .should('have.value', '/var/www/html/nextcloud')
+ })
+
+ it('Renders the htaccess warning', () => {
+ const config = {
+ ...defaultConfig,
+ htaccessWorking: false,
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form-note="htaccess"]')
+ .should('be.visible')
+ .should('contain', 'Security warning')
+ .invoke('html')
+ .should('contains', links.adminInstall)
+ })
+})
+
+describe('Setup page with autoconfig', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders autoconfig', () => {
+ const config = {
+ ...defaultConfig,
+ hasAutoconfig: true,
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Autoconfig info note
+ cy.get('[data-cy-setup-form-note="autoconfig"]')
+ .should('be.visible')
+ .should('contain', 'Autoconfig file detected')
+
+ // Database and storage section is hidden as already set in autoconfig
+ cy.get('[data-cy-setup-form-advanced-config]').should('be.visible')
+ .invoke('attr', 'open')
+ .should('equal', undefined)
+
+ // Oracle tablespace is hidden
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('not.exist')
+ })
+})
+
+describe('Submit a full form sends the data', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Submits a full form', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ dbtablespace: 'tablespace',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+
+ cy.intercept('POST', '**', {
+ delay: 2000,
+ }).as('setup')
+
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Not chaining breaks the test as the POST prevents the element from being retrieved twice
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.get('[data-cy-setup-form-submit]')
+ .click()
+ .invoke('attr', 'disabled')
+ .should('equal', 'disabled', { timeout: 500 })
+
+ cy.wait('@setup')
+ .its('request.body')
+ .should('deep.equal', new URLSearchParams({
+ adminlogin: 'admin',
+ adminpass: 'password',
+ directory: '/var/www/html/nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbname: 'nextcloud',
+ dbhost: 'localhost',
+ }).toString())
+ })
+})
diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue
new file mode 100644
index 00000000000..50ec0da9035
--- /dev/null
+++ b/core/src/views/Setup.vue
@@ -0,0 +1,460 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <form ref="form"
+ class="setup-form"
+ :class="{ 'setup-form--loading': loading }"
+ action=""
+ data-cy-setup-form
+ method="POST"
+ @submit="onSubmit">
+ <!-- Autoconfig info -->
+ <NcNoteCard v-if="config.hasAutoconfig"
+ :heading="t('core', 'Autoconfig file detected')"
+ data-cy-setup-form-note="autoconfig"
+ type="success">
+ {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
+ </NcNoteCard>
+
+ <!-- Htaccess warning -->
+ <NcNoteCard v-if="config.htaccessWorking === false"
+ :heading="t('core', 'Security warning')"
+ data-cy-setup-form-note="htaccess"
+ type="warning">
+ <p v-html="htaccessWarning" />
+ </NcNoteCard>
+
+ <!-- Various errors -->
+ <NcNoteCard v-for="(error, index) in errors"
+ :key="index"
+ :heading="error.heading"
+ data-cy-setup-form-note="error"
+ type="error">
+ {{ error.message }}
+ </NcNoteCard>
+
+ <!-- Admin creation -->
+ <fieldset class="setup-form__administration">
+ <legend>{{ t('core', 'Create administration account') }}</legend>
+
+ <!-- Username -->
+ <NcTextField v-model="config.adminlogin"
+ :label="t('core', 'Administration account name')"
+ data-cy-setup-form-field="adminlogin"
+ name="adminlogin"
+ required />
+
+ <!-- Password -->
+ <NcPasswordField v-model="config.adminpass"
+ :label="t('core', 'Administration account password')"
+ data-cy-setup-form-field="adminpass"
+ name="adminpass"
+ required />
+
+ <!-- Password entropy -->
+ <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType">
+ {{ passwordHelperText }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Autoconfig toggle -->
+ <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config>
+ <summary>{{ t('core', 'Storage & database') }}</summary>
+
+ <!-- Data folder -->
+ <fieldset class="setup-form__data-folder">
+ <NcTextField v-model="config.directory"
+ :label="t('core', 'Data folder')"
+ :placeholder="config.serverRoot + '/data'"
+ required
+ autocomplete="off"
+ autocapitalize="none"
+ data-cy-setup-form-field="directory"
+ name="directory"
+ spellcheck="false" />
+ </fieldset>
+
+ <!-- Database -->
+ <fieldset class="setup-form__database">
+ <legend>{{ t('core', 'Database configuration') }}</legend>
+
+ <!-- Database type select -->
+ <fieldset class="setup-form__database-type">
+ <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
+ <NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
+ :key="db"
+ v-model="config.dbtype"
+ :button-variant="true"
+ :data-cy-setup-form-field="`dbtype-${db}`"
+ :value="db"
+ :button-variant-grouped="DBTypeGroupDirection"
+ name="dbtype"
+ type="radio">
+ {{ name }}
+ </NcCheckboxRadioSwitch>
+ </p>
+
+ <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning">
+ {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br>
+ {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
+ <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
+ {{ t('core', 'For more details check out the documentation.') }} ↗
+ </a>
+ </NcNoteCard>
+
+ <NcNoteCard v-if="config.dbtype === 'sqlite'"
+ :heading="t('core', 'Performance warning')"
+ data-cy-setup-form-db-note="sqlite"
+ type="warning">
+ {{ t('core', 'You chose SQLite as database.') }}<br>
+ {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
+ {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Database configuration -->
+ <fieldset v-if="config.dbtype !== 'sqlite'">
+ <NcTextField v-model="config.dbuser"
+ :label="t('core', 'Database user')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbuser"
+ name="dbuser"
+ spellcheck="false"
+ required />
+
+ <NcPasswordField v-model="config.dbpass"
+ :label="t('core', 'Database password')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbpass"
+ name="dbpass"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-model="config.dbname"
+ :label="t('core', 'Database name')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbname"
+ name="dbname"
+ pattern="[0-9a-zA-Z\$_\-]+"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-if="config.dbtype === 'oci'"
+ v-model="config.dbtablespace"
+ :label="t('core', 'Database tablespace')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbtablespace"
+ name="dbtablespace"
+ spellcheck="false" />
+
+ <NcTextField v-model="config.dbhost"
+ :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')"
+ :label="t('core', 'Database host')"
+ :placeholder="t('core', 'localhost')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbhost"
+ name="dbhost"
+ spellcheck="false" />
+ </fieldset>
+ </fieldset>
+ </details>
+
+ <!-- Submit -->
+ <NcButton class="setup-form__button"
+ :class="{ 'setup-form__button--loading': loading }"
+ :disabled="loading"
+ :loading="loading"
+ :wide="true"
+ alignment="center-reverse"
+ data-cy-setup-form-submit
+ native-type="submit"
+ type="primary">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconArrowRight v-else />
+ </template>
+ {{ loading ? t('core', 'Installing …') : t('core', 'Install') }}
+ </NcButton>
+
+ <!-- Help note -->
+ <NcNoteCard data-cy-setup-form-note="help" type="info">
+ {{ t('core', 'Need help?') }}
+ <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a>
+ </NcNoteCard>
+ </form>
+</template>
+<script lang="ts">
+import type { DbType, SetupConfig, SetupLinks } from '../install'
+
+import { defineComponent } from 'vue'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import DomPurify from 'dompurify'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+
+enum PasswordStrength {
+ VeryWeak,
+ Weak,
+ Moderate,
+ Strong,
+ VeryStrong,
+ ExtremelyStrong,
+}
+
+const checkPasswordEntropy = (password: string = ''): PasswordStrength => {
+ const uniqueCharacters = new Set(password)
+ const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
+ if (entropy < 16) {
+ return PasswordStrength.VeryWeak
+ } else if (entropy < 31) {
+ return PasswordStrength.Weak
+ } else if (entropy < 46) {
+ return PasswordStrength.Moderate
+ } else if (entropy < 61) {
+ return PasswordStrength.Strong
+ } else if (entropy < 76) {
+ return PasswordStrength.VeryStrong
+ }
+
+ return PasswordStrength.ExtremelyStrong
+}
+
+export default defineComponent({
+ name: 'Setup',
+
+ components: {
+ IconArrowRight,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ NcNoteCard,
+ NcPasswordField,
+ NcTextField,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ config: {} as SetupConfig,
+ links: {} as SetupLinks,
+ isValidAutoconfig: false,
+ loading: false,
+ }
+ },
+
+ computed: {
+ passwordHelperText(): string {
+ if (this.config?.adminpass === '') {
+ return ''
+ }
+
+ const passwordStrength = checkPasswordEntropy(this.config?.adminpass)
+ switch (passwordStrength) {
+ case PasswordStrength.VeryWeak:
+ return t('core', 'Password is too weak')
+ case PasswordStrength.Weak:
+ return t('core', 'Password is weak')
+ case PasswordStrength.Moderate:
+ return t('core', 'Password is average')
+ case PasswordStrength.Strong:
+ return t('core', 'Password is strong')
+ case PasswordStrength.VeryStrong:
+ return t('core', 'Password is very strong')
+ case PasswordStrength.ExtremelyStrong:
+ return t('core', 'Password is extremely strong')
+ }
+
+ return t('core', 'Unknown password strength')
+ },
+ passwordHelperType() {
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) {
+ return 'error'
+ }
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) {
+ return 'warning'
+ }
+ return 'success'
+ },
+
+ firstAndOnlyDatabase(): string|null {
+ const dbNames = Object.values(this.config?.databases || {})
+ if (dbNames.length === 1) {
+ return dbNames[0]
+ }
+
+ return null
+ },
+
+ DBTypeGroupDirection() {
+ const databases = Object.keys(this.config?.databases || {})
+ // If we have more than 3 databases, we want to display them vertically
+ if (databases.length > 3) {
+ return 'vertical'
+ }
+ return 'horizontal'
+ },
+
+ htaccessWarning(): string {
+ // We use v-html, let's make sure we're safe
+ const message = [
+ t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
+ t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
+ linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
+ linkEnd: '</a>',
+ }, { escape: false }),
+ ].join('<br>')
+ return DomPurify.sanitize(message)
+ },
+
+ errors() {
+ return (this.config?.errors || []).map(error => {
+ if (typeof error === 'string') {
+ return {
+ heading: '',
+ message: error,
+ }
+ }
+
+ // f no hint is set, we don't want to show a heading
+ if (error.hint === '') {
+ return {
+ heading: '',
+ message: error.error,
+ }
+ }
+
+ return {
+ heading: error.error,
+ message: error.hint,
+ }
+ })
+ },
+ },
+
+ beforeMount() {
+ // Needs to only read the state once we're mounted
+ // for Cypress to be properly initialized.
+ this.config = loadState<SetupConfig>('core', 'config')
+ this.links = loadState<SetupLinks>('core', 'links')
+
+ },
+
+ mounted() {
+ // Set the first database type as default if none is set
+ if (this.config.dbtype === '') {
+ this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
+ }
+
+ // Validate the legitimacy of the autoconfig
+ if (this.config.hasAutoconfig) {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.removeAttribute('required')
+ })
+
+ if (form.checkValidity() && this.config.errors.length === 0) {
+ this.isValidAutoconfig = true
+ } else {
+ this.isValidAutoconfig = false
+ }
+
+ // Restore the required attribute
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.setAttribute('required', 'true')
+ })
+ }
+ },
+
+ methods: {
+ async onSubmit() {
+ this.loading = true
+ },
+ },
+})
+</script>
+<style lang="scss">
+form {
+ padding: calc(3 * var(--default-grid-baseline));
+ color: var(--color-main-text);
+ border-radius: var(--border-radius-container);
+ background-color: var(--color-main-background-blur);
+ box-shadow: 0 0 10px var(--color-box-shadow);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+
+ max-width: 300px;
+ margin-bottom: 30px;
+
+ > fieldset:first-child,
+ > .notecard:first-child {
+ margin-top: 0;
+ }
+
+ > .notecard:last-child {
+ margin-bottom: 0;
+ }
+
+ fieldset,
+ details {
+ margin-block: 1rem;
+ }
+
+ .setup-form__button:not(.setup-form__button--loading) {
+ .material-design-icon {
+ transition: all linear var(--animation-quick);
+ }
+
+ &:hover .material-design-icon {
+ transform: translateX(0.2em);
+ }
+ }
+
+ // Db select required styling
+ .setup-form__database-type-select {
+ display: flex;
+ &--vertical {
+ flex-direction: column;
+ }
+ }
+
+}
+
+code {
+ background-color: var(--color-background-dark);
+ margin-top: 1rem;
+ padding: 0 0.3em;
+ border-radius: var(--border-radius);
+}
+
+// Various overrides
+.input-field {
+ margin-block-start: 1rem !important;
+}
+
+.notecard__heading {
+ font-size: inherit !important;
+}
+</style>
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
index 2d2d2d0a254..103e47b0425 100644
--- a/core/src/views/UnifiedSearch.vue
+++ b/core/src/views/UnifiedSearch.vue
@@ -1,803 +1,182 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <HeaderMenu id="unified-search"
- class="unified-search"
- exclude-click-outside-classes="popover"
- :open.sync="open"
- :aria-label="ariaLabel"
- @open="onOpen"
- @close="onClose">
- <!-- Header icon -->
- <template #trigger>
- <Magnify class="unified-search__trigger"
- :size="22/* fit better next to other 20px icons */"
- fill-color="var(--color-primary-text)" />
- </template>
-
- <!-- Search form & filters wrapper -->
- <div class="unified-search__input-wrapper">
- <form class="unified-search__form"
- role="search"
- :class="{'icon-loading-small': isLoading}"
- @submit.prevent.stop="onInputEnter"
- @reset.prevent.stop="onReset">
- <!-- Search input -->
- <input ref="input"
- v-model="query"
- class="unified-search__form-input"
- type="search"
- :class="{'unified-search__form-input--with-reset': !!query}"
- :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
- @input="onInputDebounced"
- @keypress.enter.prevent.stop="onInputEnter">
-
- <!-- Reset search button -->
- <input v-if="!!query && !isLoading"
- type="reset"
- class="unified-search__form-reset icon-close"
- :aria-label="t('core','Reset search')"
- value="">
-
- <input v-if="!!query && !isLoading && !enableLiveSearch"
- type="submit"
- class="unified-search__form-submit icon-confirm"
- :aria-label="t('core','Start search')"
- value="">
- </form>
-
- <!-- Search filters -->
- <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
- <ActionButton v-for="type in availableFilters"
- :key="type"
- icon="icon-filter"
- :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
- @click="onClickFilter(`in:${type}`)">
- {{ `in:${type}` }}
- </ActionButton>
- </Actions>
- </div>
-
- <template v-if="!hasResults">
- <!-- Loading placeholders -->
- <SearchResultPlaceholders v-if="isLoading" />
-
- <EmptyContent v-else-if="isValidQuery">
- <Highlight v-if="triggered" :text="t('core', 'No results for {query}', { query })" :search="query" />
- <div v-else>
- {{ t('core', 'Press enter to start searching') }}
- </div>
- <template #icon>
- <Magnify />
- </template>
- </EmptyContent>
-
- <EmptyContent v-else-if="!isLoading || isShortQuery">
- {{ t('core', 'Start typing to search') }}
- <template #icon>
- <Magnify />
- </template>
- <template v-if="isShortQuery" #desc>
- {{ n('core',
- 'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
- minSearchLength,
- {minSearchLength}) }}
- </template>
- </EmptyContent>
- </template>
-
- <!-- Grouped search results -->
- <template v-else>
- <ul v-for="({list, type}, typesIndex) in orderedResults"
- :key="type"
- class="unified-search__results"
- :class="`unified-search__results-${type}`"
- :aria-label="typesMap[type]">
- <!-- Search results -->
- <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
- <SearchResult v-bind="result"
- :query="query"
- :focused="focused === 0 && typesIndex === 0 && index === 0"
- @focus="setFocusedIndex" />
- </li>
-
- <!-- Load more button -->
- <li>
- <SearchResult v-if="!reached[type]"
- class="unified-search__result-more"
- :title="loading[type]
- ? t('core', 'Loading more results …')
- : t('core', 'Load more results')"
- :icon-class="loading[type] ? 'icon-loading-small' : ''"
- @click.prevent="loadMore(type)"
- @focus="setFocusedIndex" />
- </li>
- </ul>
- </template>
- </HeaderMenu>
+ <div class="unified-search-menu">
+ <NcHeaderButton v-show="!showLocalSearch"
+ :aria-label="t('core', 'Unified search')"
+ @click="toggleUnifiedSearch">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcHeaderButton>
+ <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch"
+ :open.sync="showLocalSearch"
+ :query.sync="queryText"
+ @global-search="openModal" />
+ <UnifiedSearchModal :local-search="supportsLocalSearch"
+ :query.sync="queryText"
+ :open.sync="showUnifiedSearch" />
+ </div>
</template>
-<script>
-import { emit } from '@nextcloud/event-bus'
-import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService'
-import { showError } from '@nextcloud/dialogs'
-
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import Actions from '@nextcloud/vue/dist/Components/Actions'
+<script lang="ts">
+import { mdiMagnify } from '@mdi/js'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+import { useBrowserLocation } from '@vueuse/core'
import debounce from 'debounce'
-import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
-import Highlight from '@nextcloud/vue/dist/Components/Highlight'
-import Magnify from 'vue-material-design-icons/Magnify'
-
-import HeaderMenu from '../components/HeaderMenu'
-import SearchResult from '../components/UnifiedSearch/SearchResult'
-import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
-
-const REQUEST_FAILED = 0
-const REQUEST_OK = 1
-const REQUEST_CANCELED = 2
-
-export default {
+import { defineComponent } from 'vue'
+import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue'
+import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue'
+import logger from '../logger.js'
+
+export default defineComponent({
name: 'UnifiedSearch',
components: {
- ActionButton,
- Actions,
- EmptyContent,
- HeaderMenu,
- Highlight,
- Magnify,
- SearchResult,
- SearchResultPlaceholders,
+ NcHeaderButton,
+ NcIconSvgWrapper,
+ UnifiedSearchModal,
+ UnifiedSearchLocalSearchBar,
},
- data() {
- return {
- types: [],
-
- // Cursors per types
- cursors: {},
- // Various search limits per types
- limits: {},
- // Loading types
- loading: {},
- // Reached search types
- reached: {},
- // Pending cancellable requests
- requests: [],
- // List of all results
- results: {},
+ setup() {
+ const currentLocation = useBrowserLocation()
- query: '',
- focused: null,
- triggered: false,
+ return {
+ currentLocation,
- defaultLimit,
- minSearchLength,
- enableLiveSearch,
+ mdiMagnify,
+ t,
+ }
+ },
- open: false,
+ data() {
+ return {
+ /** The current search query */
+ queryText: '',
+ /** Open state of the modal */
+ showUnifiedSearch: false,
+ /** Open state of the local search bar */
+ showLocalSearch: false,
}
},
computed: {
- typesIDs() {
- return this.types.map(type => type.id)
- },
- typesNames() {
- return this.types.map(type => type.name)
- },
- typesMap() {
- return this.types.reduce((prev, curr) => {
- prev[curr.id] = curr.name
- return prev
- }, {})
- },
-
- ariaLabel() {
- return t('core', 'Search')
- },
-
- /**
- * Is there any result to display
- *
- * @return {boolean}
- */
- hasResults() {
- return Object.keys(this.results).length !== 0
- },
-
/**
- * Return ordered results
- *
- * @return {Array}
+ * Debounce emitting the search query by 250ms
*/
- orderedResults() {
- return this.typesIDs
- .filter(type => type in this.results)
- .map(type => ({
- type,
- list: this.results[type],
- }))
+ debouncedQueryUpdate() {
+ return debounce(this.emitUpdatedQuery, 250)
},
/**
- * Available filters
- * We only show filters that are available on the results
- *
- * @return {string[]}
+ * Current page (app) supports local in-app search
*/
- availableFilters() {
- return Object.keys(this.results)
- },
-
- /**
- * Applied filters
- *
- * @return {string[]}
- */
- usedFiltersIn() {
- let match
- const filters = []
- while ((match = regexFilterIn.exec(this.query)) !== null) {
- filters.push(match[1])
- }
- return filters
- },
-
- /**
- * Applied anti filters
- *
- * @return {string[]}
- */
- usedFiltersNot() {
- let match
- const filters = []
- while ((match = regexFilterNot.exec(this.query)) !== null) {
- filters.push(match[1])
- }
- return filters
- },
-
- /**
- * Is the current search too short
- *
- * @return {boolean}
- */
- isShortQuery() {
- return this.query && this.query.trim().length < minSearchLength
- },
-
- /**
- * Is the current search valid
- *
- * @return {boolean}
- */
- isValidQuery() {
- return this.query && this.query.trim() !== '' && !this.isShortQuery
- },
-
- /**
- * Have we reached the end of all types searches
- *
- * @return {boolean}
- */
- isDoneSearching() {
- return Object.values(this.reached).every(state => state === false)
+ supportsLocalSearch() {
+ // TODO: Make this an API
+ const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
+ return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
},
+ },
+ watch: {
/**
- * Is there any search in progress
- *
- * @return {boolean}
+ * Emit the updated query as eventbus events
+ * (This is debounced)
*/
- isLoading() {
- return Object.values(this.loading).some(state => state === true)
+ queryText() {
+ this.debouncedQueryUpdate()
},
},
- async created() {
- this.types = await getTypes()
- this.logger.debug('Unified Search initialized with the following providers', this.types)
- },
-
mounted() {
- document.addEventListener('keydown', (event) => {
- // if not already opened, allows us to trigger default browser on second keydown
- if (event.ctrlKey && event.key === 'f' && !this.open) {
- event.preventDefault()
- this.open = true
- this.focusInput()
- }
-
- // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
- if (this.open) {
- // If arrow down, focus next result
- if (event.key === 'ArrowDown') {
- this.focusNext(event)
- }
+ // register keyboard listener for search shortcut
+ if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) {
+ window.addEventListener('keydown', this.onKeyDown)
+ }
- // If arrow up, focus prev result
- if (event.key === 'ArrowUp') {
- this.focusPrev(event)
- }
- }
+ // Allow external reset of the search / close local search
+ subscribe('nextcloud:unified-search:reset', () => {
+ this.showLocalSearch = false
+ this.queryText = ''
})
- },
-
- methods: {
- async onOpen() {
- this.focusInput()
- // Update types list in the background
- this.types = await getTypes()
- },
- onClose() {
- emit('nextcloud:unified-search.close')
- },
-
- /**
- * Reset the search state
- */
- onReset() {
- emit('nextcloud:unified-search.reset')
- this.logger.debug('Search reset')
- this.query = ''
- this.resetState()
- this.focusInput()
- },
- async resetState() {
- this.cursors = {}
- this.limits = {}
- this.reached = {}
- this.results = {}
- this.focused = null
- this.triggered = false
- await this.cancelPendingRequests()
- },
-
- /**
- * Cancel any ongoing searches
- */
- async cancelPendingRequests() {
- // Cloning so we can keep processing other requests
- const requests = this.requests.slice(0)
- this.requests = []
-
- // Cancel all pending requests
- await Promise.all(requests.map(cancel => cancel()))
- },
-
- /**
- * Focus the search input on next tick
- */
- focusInput() {
- this.$nextTick(() => {
- this.$refs.input.focus()
- this.$refs.input.select()
- })
- },
-
- /**
- * If we have results already, open first one
- * If not, trigger the search again
- */
- onInputEnter() {
- if (this.hasResults) {
- const results = this.getResultsList()
- results[0].click()
- return
- }
- this.onInput()
- },
-
- /**
- * Start searching on input
- */
- async onInput() {
- // emit the search query
- emit('nextcloud:unified-search.search', { query: this.query })
-
- // Do not search if not long enough
- if (this.query.trim() === '' || this.isShortQuery) {
- return
- }
- let types = this.typesIDs
- let query = this.query
-
- // Filter out types
- if (this.usedFiltersNot.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
- }
-
- // Only use those filters if any and check if they are valid
- if (this.usedFiltersIn.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
- }
-
- // Remove any filters from the query
- query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
-
- // Reset search if the query changed
- await this.resetState()
- this.triggered = true
- this.$set(this.loading, 'all', true)
- this.logger.debug(`Searching ${query} in`, types)
-
- Promise.all(types.map(async type => {
- try {
- // Init cancellable request
- const { request, cancel } = search({ type, query })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.$set(this.results, type, data.ocs.data.entries)
- } else {
- this.$delete(this.results, type)
- }
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- } else if (!data.ocs.data.isPaginated) {
- // If no cursor and no pagination, we save the default amount
- // provided by server's initial state `defaultLimit`
- this.$set(this.limits, type, this.defaultLimit)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
-
- // If none already focused, focus the first rendered result
- if (this.focused === null) {
- this.focused = 0
- }
- return REQUEST_OK
- } catch (error) {
- this.$delete(this.results, type)
-
- // If this is not a cancelled throw
- if (error.response && error.response.status) {
- this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
- showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
- return REQUEST_FAILED
- }
- return REQUEST_CANCELED
- }
- })).then(results => {
- // Do not declare loading finished if the request have been cancelled
- // This means another search was triggered and we're therefore still loading
- if (results.some(result => result === REQUEST_CANCELED)) {
- return
- }
- // We finished all searches
- this.loading = {}
- })
- },
- onInputDebounced: enableLiveSearch
- ? debounce(function(e) {
- this.onInput(e)
- }, 500)
- : function() {
- this.triggered = false
- },
-
- /**
- * Load more results for the provided type
- *
- * @param {string} type type
- */
- async loadMore(type) {
- // If already loading, ignore
- if (this.loading[type]) {
- return
- }
-
- if (this.cursors[type]) {
- // Init cancellable request
- const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- }
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.results[type].push(...data.ocs.data.entries)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
- } else
-
- // If no cursor, we might have all the results already,
- // let's fake pagination and show the next xxx entries
- if (this.limits[type] && this.limits[type] >= 0) {
- this.limits[type] += this.defaultLimit
-
- // Check if we reached end of pagination
- if (this.limits[type] >= this.results[type].length) {
- this.$set(this.reached, type, true)
- }
- }
-
- // Focus result after render
- if (this.focused !== null) {
- this.$nextTick(() => {
- this.focusIndex(this.focused)
- })
- }
- },
+ // Deprecated events to be removed
+ subscribe('nextcloud:unified-search:reset', () => {
+ emit('nextcloud:unified-search.reset', { query: '' })
+ })
+ subscribe('nextcloud:unified-search:search', ({ query }) => {
+ emit('nextcloud:unified-search.search', { query })
+ })
- /**
- * Return a subset of the array if the search provider
- * doesn't supports pagination
- *
- * @param {Array} list the results
- * @param {string} type the type
- * @return {Array}
- */
- limitIfAny(list, type) {
- if (type in this.limits) {
- return list.slice(0, this.limits[type])
- }
- return list
- },
+ // all done
+ logger.debug('Unified search initialized!')
+ },
- getResultsList() {
- return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
- },
+ beforeDestroy() {
+ // keep in mind to remove the event listener
+ window.removeEventListener('keydown', this.onKeyDown)
+ },
+ methods: {
/**
- * Focus the first result if any
- *
- * @param {Event} event the keydown event
+ * Handle the key down event to open search on `ctrl + F`
+ * @param event The keyboard event
*/
- focusFirst(event) {
- const results = this.getResultsList()
- if (results && results.length > 0) {
- if (event) {
+ onKeyDown(event: KeyboardEvent) {
+ if (event.ctrlKey && event.key === 'f') {
+ // only handle search if not already open - in this case the browser native search should be used
+ if (!this.showLocalSearch && !this.showUnifiedSearch) {
event.preventDefault()
}
- this.focused = 0
- this.focusIndex(this.focused)
+ this.toggleUnifiedSearch()
}
},
/**
- * Focus the next result if any
- *
- * @param {Event} event the keydown event
+ * Toggle the local search if available - otherwise open the unified search modal
*/
- focusNext(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
+ toggleUnifiedSearch() {
+ if (this.supportsLocalSearch) {
+ this.showLocalSearch = !this.showLocalSearch
+ } else {
+ this.showUnifiedSearch = !this.showUnifiedSearch
+ this.showLocalSearch = false
}
-
- const results = this.getResultsList()
- // If we're not focusing the last, focus the next one
- if (results && results.length > 0 && this.focused + 1 < results.length) {
- event.preventDefault()
- this.focused++
- this.focusIndex(this.focused)
- }
- },
-
- /**
- * Focus the previous result if any
- *
- * @param {Event} event the keydown event
- */
- focusPrev(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the first, focus the previous one
- if (results && results.length > 0 && this.focused > 0) {
- event.preventDefault()
- this.focused--
- this.focusIndex(this.focused)
- }
-
},
/**
- * Focus the specified result index if it exists
- *
- * @param {number} index the result index
+ * Open the unified search modal
*/
- focusIndex(index) {
- const results = this.getResultsList()
- if (results && results[index]) {
- results[index].focus()
- }
+ openModal() {
+ this.showUnifiedSearch = true
+ this.showLocalSearch = false
},
/**
- * Set the current focused element based on the target
- *
- * @param {Event} event the focus event
+ * Emit the updated search query as eventbus events
*/
- setFocusedIndex(event) {
- const entry = event.target
- const results = this.getResultsList()
- const index = [...results].findIndex(search => search === entry)
- if (index > -1) {
- // let's not use focusIndex as the entry is already focused
- this.focused = index
+ emitUpdatedQuery() {
+ if (this.queryText === '') {
+ emit('nextcloud:unified-search:reset')
+ } else {
+ emit('nextcloud:unified-search:search', { query: this.queryText })
}
},
-
- onClickFilter(filter) {
- this.query = `${this.query} ${filter}`
- .replace(/ {2}/g, ' ')
- .trim()
- this.onInput()
- },
},
-}
+})
</script>
<style lang="scss" scoped>
-@use "sass:math";
-
-$margin: 10px;
-$input-height: 34px;
-$input-padding: 6px;
-
-.unified-search {
- &__input-wrapper {
- position: sticky;
- // above search results
- z-index: 2;
- top: 0;
- display: inline-flex;
- align-items: center;
- width: 100%;
- background-color: var(--color-main-background);
- }
-
- &__filters {
- margin: math.div($margin, 2) $margin;
- ul {
- display: inline-flex;
- justify-content: space-between;
- }
- }
-
- &__form {
- position: relative;
- width: 100%;
- margin: $margin;
-
- // Loading spinner
- &::after {
- right: $input-padding;
- left: auto;
- }
-
- &-input,
- &-reset {
- margin: math.div($input-padding, 2);
- }
-
- &-input {
- width: 100%;
- height: $input-height;
- padding: $input-padding;
-
- &,
- &[placeholder],
- &::placeholder {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- // Hide webkit clear search
- &::-webkit-search-decoration,
- &::-webkit-search-cancel-button,
- &::-webkit-search-results-button,
- &::-webkit-search-results-decoration {
- -webkit-appearance: none;
- }
-
- // Ellipsis earlier if reset button is here
- .icon-loading-small &,
- &--with-reset {
- padding-right: $input-height;
- }
- }
-
- &-reset, &-submit {
- position: absolute;
- top: 0;
- right: 0;
- width: $input-height - $input-padding;
- height: $input-height - $input-padding;
- padding: 0;
- opacity: .5;
- border: none;
- background-color: transparent;
- margin-right: 0;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 1;
- }
- }
-
- &-submit {
- right: 28px;
- }
- }
-
- &__filters {
- margin-right: math.div($margin, 2);
- }
-
- &__results {
- &::before {
- display: block;
- margin: $margin;
- margin-left: $margin + $input-padding;
- content: attr(aria-label);
- color: var(--color-primary-element);
- }
- }
-
- .unified-search__result-more::v-deep {
- color: var(--color-text-maxcontrast);
- }
-
- .empty-content {
- margin: 10vh 0;
-
- ::v-deep .empty-content__title {
- font-weight: normal;
- font-size: var(--default-font-size);
- padding: 0 15px;
- text-align: center;
- }
- }
+// this is needed to allow us overriding component styles (focus-visible)
+.unified-search-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
-
</style>
diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue
new file mode 100644
index 00000000000..408cccf61e9
--- /dev/null
+++ b/core/src/views/UnsupportedBrowser.vue
@@ -0,0 +1,187 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="content-unsupported-browser guest-box">
+ <NcEmptyContent>
+ {{ t('core', 'This browser is not supported') }}
+ <template #icon>
+ <Web />
+ </template>
+ <template #action>
+ <div>
+ <h2>
+ {{ t('core', 'Your browser is not supported. Please upgrade to a newer version or a supported one.') }}
+ </h2>
+ <NcButton class="content-unsupported-browser__continue" type="primary" @click="forceBrowsing">
+ {{ t('core', 'Continue with this unsupported browser') }}
+ </NcButton>
+ </div>
+
+ <ul class="content-unsupported-browser__list">
+ <h3>{{ t('core', 'Supported versions') }}</h3>
+ <li v-for="browser in formattedBrowsersList" :key="browser">
+ {{ browser }}
+ </li>
+ </ul>
+ </template>
+ </NcEmptyContent>
+ </div>
+</template>
+
+<script>
+// eslint-disable-next-line n/no-extraneous-import
+import { agents } from 'caniuse-lite/dist/unpacker/agents.js'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import Web from 'vue-material-design-icons/Web.vue'
+
+import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js'
+import { supportedBrowsers } from '../services/BrowsersListService.js'
+import browserStorage from '../services/BrowserStorageService.js'
+import logger from '../logger.js'
+
+logger.debug('Supported browsers', { supportedBrowsers })
+
+export default {
+ name: 'UnsupportedBrowser',
+ components: {
+ Web,
+ NcButton,
+ NcEmptyContent,
+ },
+
+ computed: {
+ isMobile() {
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+ },
+
+ /**
+ * Filter out or include mobile/desktop browsers depending
+ * on the current user platform/device
+ */
+ filteredSupportedBrowsers() {
+ return supportedBrowsers.filter(browser => {
+ if (!browser) {
+ return false
+ }
+
+ if (this.isMobile) {
+ return this.isMobileBrowser(browser)
+ }
+ return !this.isMobileBrowser(browser)
+ })
+ },
+
+ formattedBrowsersList() {
+ const list = {}
+
+ // supportedBrowsers is generated by webpack at compilation time
+ this.filteredSupportedBrowsers.forEach(browser => {
+ const [id, version] = browser.split(' ')
+ if (!list[id] || list[id] < parseFloat(version, 10)) {
+ list[id] = parseFloat(version, 10)
+ }
+ })
+
+ return Object.keys(list).map(id => {
+ if (!agents[id]?.browser) {
+ return null
+ }
+
+ const version = list[id]
+ const name = agents[id]?.browser
+ return this.t('core', '{name} version {version} and above', {
+ name, version,
+ })
+ }).filter(entry => entry !== null)
+ },
+ },
+
+ methods: {
+ t,
+ n,
+
+ // Set the flag allowing this browser and redirect to home
+ forceBrowsing() {
+ browserStorage.setItem(browserStorageKey, true)
+
+ // Redirect if there is the data
+ const urlParams = new URLSearchParams(window.location.search)
+ if (urlParams.has('redirect_url')) {
+ let redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/'
+
+ // remove index.php and double slashes
+ redirectPath = redirectPath
+ .replace('index.php', '')
+ .replace(getRootUrl(), '')
+ .replace(/\/\//g, '/')
+
+ // if we have a valid redirect url, use it
+ if (redirectPath.startsWith('/')) {
+ window.location = generateUrl(redirectPath)
+ return
+ }
+ }
+
+ // else redirect to root
+ window.location = generateUrl('/')
+ },
+
+ /**
+ * Detect if the browserslist browser is a mobile one
+ * https://github.com/browserslist/browserslist#query-composition
+ *
+ * @param {string} browser a valid browserlist browser. e.g `and_chr 90`
+ */
+ isMobileBrowser(browser) {
+ browser = browser.toLowerCase()
+ return browser.includes('and_')
+ || browser.includes('android')
+ || browser.includes('ios_')
+ || browser.includes('mobile')
+ || browser.includes('_mob')
+ || browser.includes('samsung')
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+$spacing: 30px;
+
+.content-unsupported-browser {
+ display: flex;
+ justify-content: center;
+ width: 400px;
+ max-width: calc(90vw - 2 * $spacing);
+ margin: auto;
+ padding: $spacing;
+
+ .empty-content {
+ margin: 0;
+
+ :deep(.empty-content__icon) {
+ opacity: 1;
+ }
+ }
+
+ &__continue {
+ display: block;
+ margin: $spacing auto;
+ }
+
+ &__list {
+ margin-top: 2 * $spacing;
+ margin-bottom: $spacing;
+ li {
+ text-align: start;
+ }
+ }
+}
+
+</style>