diff options
Diffstat (limited to 'core/src/views')
-rw-r--r-- | core/src/views/AccountMenu.vue | 247 | ||||
-rw-r--r-- | core/src/views/ContactsMenu.vue | 233 | ||||
-rw-r--r-- | core/src/views/LegacyUnifiedSearch.vue | 848 | ||||
-rw-r--r-- | core/src/views/Login.vue | 233 | ||||
-rw-r--r-- | core/src/views/Profile.vue | 587 | ||||
-rw-r--r-- | core/src/views/PublicPageMenu.vue | 131 | ||||
-rw-r--r-- | core/src/views/PublicPageUserMenu.vue | 138 | ||||
-rw-r--r-- | core/src/views/Setup.cy.ts | 369 | ||||
-rw-r--r-- | core/src/views/Setup.vue | 460 | ||||
-rw-r--r-- | core/src/views/UnifiedSearch.vue | 861 | ||||
-rw-r--r-- | core/src/views/UnsupportedBrowser.vue | 187 |
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> |