diff options
Diffstat (limited to 'core/src')
26 files changed, 771 insertions, 330 deletions
diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js index c10f676701d..5c6934e67a2 100644 --- a/core/src/OC/dialogs.js +++ b/core/src/OC/dialogs.js @@ -9,7 +9,7 @@ import _ from 'underscore' import $ from 'jquery' import IconMove from '@mdi/svg/svg/folder-move.svg?raw' -import IconCopy from '@mdi/svg/svg/folder-multiple.svg?raw' +import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw' import OC from './index.js' import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs' @@ -278,13 +278,13 @@ const Dialogs = { } else { builder.setButtonFactory((nodes, path) => { const buttons = [] - const node = nodes?.[0]?.attributes?.displayName || nodes?.[0]?.basename - const target = node || basename(path) + const [node] = nodes + const target = node?.displayname || node?.basename || basename(path) if (type === FilePickerType.Choose) { buttons.push({ callback: legacyCallback(callback, FilePickerType.Choose), - label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: node }) : t('core', 'Choose'), + label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'), type: 'primary', }) } diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js index bdafa364beb..090c351c057 100644 --- a/core/src/OC/eventsource.js +++ b/core/src/OC/eventsource.js @@ -7,7 +7,7 @@ /* eslint-disable */ import $ from 'jquery' -import { getToken } from './requesttoken.js' +import { getRequestToken } from './requesttoken.ts' /** * Create a new event source @@ -28,7 +28,7 @@ const OCEventSource = function(src, data) { dataStr += name + '=' + encodeURIComponent(data[name]) + '&' } } - dataStr += 'requesttoken=' + encodeURIComponent(getToken()) + dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken()) if (!this.useFallBack && typeof EventSource !== 'undefined') { joinChar = '&' if (src.indexOf('?') === -1) { diff --git a/core/src/OC/index.js b/core/src/OC/index.js index eff3289308a..5afc941b396 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -49,9 +49,7 @@ import { getPort, getProtocol, } from './host.js' -import { - getToken as getRequestToken, -} from './requesttoken.js' +import { getRequestToken } from './requesttoken.ts' import { hideMenus, registerMenu, diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js deleted file mode 100644 index ed89af59c17..00000000000 --- a/core/src/OC/requesttoken.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { emit } from '@nextcloud/event-bus' - -/** - * @private - * @param {Document} global the document to read the initial value from - * @param {Function} emit the function to invoke for every new token - * @return {object} - */ -export const manageToken = (global, emit) => { - let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken') - - return { - getToken: () => token, - setToken: newToken => { - token = newToken - - emit('csrf-token-update', { - token, - }) - }, - } -} - -const manageFromDocument = manageToken(document, emit) - -/** - * @return {string} - */ -export const getToken = manageFromDocument.getToken - -/** - * @param {string} newToken new token - */ -export const setToken = manageFromDocument.setToken diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts new file mode 100644 index 00000000000..8ecf0b3de7e --- /dev/null +++ b/core/src/OC/requesttoken.ts @@ -0,0 +1,49 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' + +/** + * Get the current CSRF token. + */ +export function getRequestToken(): string { + return document.head.dataset.requesttoken! +} + +/** + * Set a new CSRF token (e.g. because of session refresh). + * This also emits an event bus event for the updated token. + * + * @param token - The new token + * @fires Error - If the passed token is not a potential valid token + */ +export function setRequestToken(token: string): void { + if (!token || typeof token !== 'string') { + throw new Error('Invalid CSRF token given', { cause: { token } }) + } + + document.head.dataset.requesttoken = token + emit('csrf-token-update', { token }) +} + +/** + * Fetch the request token from the API. + * This does also set it on the current context, see `setRequestToken`. + * + * @fires Error - If the request failed + */ +export async function fetchRequestToken(): Promise<string> { + const url = generateUrl('/csrftoken') + + const response = await fetch(url) + if (!response.ok) { + throw new Error('Could not fetch CSRF token from API', { cause: response }) + } + + const { token } = await response.json() + setRequestToken(token) + return token +} diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue index 47db84a7d33..d983226d273 100644 --- a/core/src/components/AccountMenu/AccountMenuEntry.vue +++ b/core/src/components/AccountMenu/AccountMenuEntry.vue @@ -11,28 +11,30 @@ compact :href="href" :name="name" - target="_self"> + target="_self" + @click="onClick"> <template #icon> - <img class="account-menu-entry__icon" + <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" /> + <slot v-else-if="$scopedSlots.icon" name="icon" /> + <img v-else + class="account-menu-entry__icon" :class="{ 'account-menu-entry__icon--active': active }" :src="iconSource" alt=""> </template> - <template v-if="loading" #indicator> - <NcLoadingIcon /> - </template> </NcListItem> </template> -<script> +<script lang="ts"> import { loadState } from '@nextcloud/initial-state' +import { defineComponent } from 'vue' import NcListItem from '@nextcloud/vue/components/NcListItem' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' const versionHash = loadState('core', 'versionHash', '') -export default { +export default defineComponent({ name: 'AccountMenuEntry', components: { @@ -55,11 +57,11 @@ export default { }, active: { type: Boolean, - required: true, + default: false, }, icon: { type: String, - required: true, + default: '', }, }, @@ -76,11 +78,17 @@ export default { }, methods: { - handleClick() { - this.loading = true + onClick(e: MouseEvent) { + this.$emit('click', e) + + // Allow to not show the loading indicator + // in case the click event was already handled + if (!e.defaultPrevented) { + this.loading = true + } }, }, -} +}) </script> <style lang="scss" scoped> @@ -96,6 +104,12 @@ export default { } } + &__loading { + height: 20px; + width: 20px; + margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size + } + :deep(.list-item-content__main) { width: fit-content; } diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue index f2cee75e644..1b0d48daf8c 100644 --- a/core/src/components/AppMenuIcon.vue +++ b/core/src/components/AppMenuIcon.vue @@ -14,24 +14,25 @@ </template> <script setup lang="ts"> -import type { INavigationEntry } from '../types/navigation' +import type { INavigationEntry } from '../types/navigation.ts' + import { n } from '@nextcloud/l10n' import { computed } from 'vue' - -import IconDot from 'vue-material-design-icons/Circle.vue' +import IconDot from 'vue-material-design-icons/CircleOutline.vue' const props = defineProps<{ app: INavigationEntry }>() -const ariaHidden = computed(() => String(props.app.unread > 0)) +// only hide if there are no unread notifications +const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined) const ariaLabel = computed(() => { - if (ariaHidden.value === 'true') { - return '' + if (!props.app.unread) { + return undefined } - return props.app.name - + (props.app.unread > 0 ? ` (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` : '') + + return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` }) </script> @@ -51,6 +52,7 @@ $unread-indicator-size: 10px; height: $icon-size; width: $icon-size; filter: var(--background-image-invert-if-bright); + mask: var(--header-menu-icon-mask); } &__unread { diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue index 4a8640f38a8..413806c7089 100644 --- a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue +++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue @@ -11,22 +11,24 @@ role="presentation" @click="$emit('click')"> <template #icon> - <div role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" /> + <slot v-if="$scopedSlots.icon" name="icon" /> + <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" /> </template> </NcListItem> </template> <script setup lang="ts"> -import NcListItem from '@nextcloud/vue/components/NcListItem' import { onMounted } from 'vue' +import NcListItem from '@nextcloud/vue/components/NcListItem' + const props = defineProps<{ /** Only emit click event but do not open href */ clickOnly?: boolean // menu entry props id: string label: string - icon: string + icon?: string href: string details?: string }>() diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue index 1860c54e1ff..171eada8a06 100644 --- a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue +++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue @@ -32,7 +32,7 @@ {{ t('core', 'Search everywhere') }} </template> <template #icon> - <NcIconSvgWrapper :path="mdiCloudSearch" /> + <NcIconSvgWrapper :path="mdiCloudSearchOutline" /> </template> </NcButton> </div> @@ -41,7 +41,7 @@ <script lang="ts" setup> import type { ComponentPublicInstance } from 'vue' -import { mdiCloudSearch, mdiClose } from '@mdi/js' +import { mdiCloudSearchOutline, mdiClose } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' import { useElementSize } from '@vueuse/core' diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue index 1edfbd45746..002606f058b 100644 --- a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -159,8 +159,8 @@ import debounce from 'debounce' import { unifiedSearchLogger } from '../../logger' import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' -import IconAccountGroup from 'vue-material-design-icons/AccountGroup.vue' -import IconCalendarRange from 'vue-material-design-icons/CalendarRange.vue' +import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue' +import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue' import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' import IconFilter from 'vue-material-design-icons/Filter.vue' import IconListBox from 'vue-material-design-icons/ListBox.vue' @@ -329,7 +329,13 @@ export default defineComponent({ query: { immediate: true, handler() { - this.searchQuery = this.query.trim() + this.searchQuery = this.query + }, + }, + + searchQuery: { + handler() { + this.$emit('update:query', this.searchQuery) }, }, }, diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue index bbca2ebf31d..bc4d25bf70f 100644 --- a/core/src/components/login/PasswordLessLoginForm.vue +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -57,7 +57,7 @@ import { import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcTextField from '@nextcloud/vue/components/NcTextField' -import InformationIcon from 'vue-material-design-icons/Information.vue' +import InformationIcon from 'vue-material-design-icons/InformationOutline.vue' import LoginButton from './LoginButton.vue' import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue' import logger from '../../logger' diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index b31e4b54ca4..f2120c28402 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -38,17 +38,16 @@ <div class="dialog-row"> <NcButton v-if="showInstallButton && !installingApps" - type="tertiary" - role="link" + data-cy-setup-recommended-apps-skip :href="defaultPageUrl" - data-cy-setup-recommended-apps-skip> + variant="tertiary"> {{ t('core', 'Skip') }} </NcButton> <NcButton v-if="showInstallButton" - type="primary" + data-cy-setup-recommended-apps-install :disabled="installingApps || !isAnyAppSelected" - data-cy-setup-recommended-apps-install> + variant="primary" @click.stop.prevent="installApps"> {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }} </NcButton> diff --git a/core/src/globals.js b/core/src/globals.js index 8511b699563..4b07cc17c3e 100644 --- a/core/src/globals.js +++ b/core/src/globals.js @@ -29,7 +29,7 @@ import 'strengthify/strengthify.css' import OC from './OC/index.js' import OCP from './OCP/index.js' import OCA from './OCA/index.js' -import { getToken as getRequestToken } from './OC/requesttoken.js' +import { getRequestToken } from './OC/requesttoken.ts' const warnIfNotTesting = function() { if (window.TESTING === undefined) { diff --git a/core/src/init.js b/core/src/init.js index 9e10a6941e1..1bcd8218702 100644 --- a/core/src/init.js +++ b/core/src/init.js @@ -8,8 +8,8 @@ import _ from 'underscore' import $ from 'jquery' import moment from 'moment' -import { initSessionHeartBeat } from './session-heartbeat.js' import OC from './OC/index.js' +import { initSessionHeartBeat } from './session-heartbeat.ts' import { setUp as setUpContactsMenu } from './components/ContactsMenu.js' import { setUp as setUpMainMenu } from './components/MainMenu.js' import { setUp as setUpUserMenu } from './components/UserMenu.js' diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js index c2868e2728a..1e9e06515a6 100644 --- a/core/src/jquery/requesttoken.js +++ b/core/src/jquery/requesttoken.js @@ -5,11 +5,11 @@ import $ from 'jquery' -import { getToken } from '../OC/requesttoken.js' +import { getRequestToken } from '../OC/requesttoken.ts' $(document).on('ajaxSend', function(elm, xhr, settings) { if (settings.crossDomain === false) { - xhr.setRequestHeader('requesttoken', getToken()) + xhr.setRequestHeader('requesttoken', getRequestToken()) xhr.setRequestHeader('OCS-APIREQUEST', 'true') } }) diff --git a/core/src/public-page-user-menu.ts b/core/src/public-page-user-menu.ts new file mode 100644 index 00000000000..25024271fb5 --- /dev/null +++ b/core/src/public-page-user-menu.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import Vue from 'vue' + +import PublicPageUserMenu from './views/PublicPageUserMenu.vue' + +__webpack_nonce__ = getCSPNonce() + +const View = Vue.extend(PublicPageUserMenu) +const instance = new View() +instance.$mount('#public-page-user-menu') diff --git a/core/src/session-heartbeat.js b/core/src/session-heartbeat.js deleted file mode 100644 index 3bd4d6b9ccd..00000000000 --- a/core/src/session-heartbeat.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import $ from 'jquery' -import { emit } from '@nextcloud/event-bus' -import { loadState } from '@nextcloud/initial-state' -import { getCurrentUser } from '@nextcloud/auth' -import { generateUrl } from '@nextcloud/router' - -import OC from './OC/index.js' -import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken.js' - -let config = null -/** - * The legacy jsunit tests overwrite OC.config before calling initCore - * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat - */ -const loadConfig = () => { - try { - config = loadState('core', 'config') - } catch (e) { - // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls - config = OC.config - } -} - -/** - * session heartbeat (defaults to enabled) - * - * @return {boolean} - */ -const keepSessionAlive = () => { - return config.session_keepalive === undefined - || !!config.session_keepalive -} - -/** - * get interval in seconds - * - * @return {number} - */ -const getInterval = () => { - let interval = NaN - if (config.session_lifetime) { - interval = Math.floor(config.session_lifetime / 2) - } - - // minimum one minute, max 24 hours, default 15 minutes - return Math.min( - 24 * 3600, - Math.max( - 60, - isNaN(interval) ? 900 : interval, - ), - ) -} - -const getToken = async () => { - const url = generateUrl('/csrftoken') - - // Not using Axios here as Axios is not stubbable with the sinon fake server - // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises - // see js/tests/specs/coreSpec.js for the tests - const resp = await $.get(url) - - return resp.token -} - -const poll = async () => { - try { - const token = await getToken() - setRequestToken(token) - } catch (e) { - console.error('session heartbeat failed', e) - } -} - -const startPolling = () => { - const interval = setInterval(poll, getInterval() * 1000) - - console.info('session heartbeat polling started') - - return interval -} - -const registerAutoLogout = () => { - if (!config.auto_logout || !getCurrentUser()) { - return - } - - let lastActive = Date.now() - window.addEventListener('mousemove', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('touchstart', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('storage', e => { - if (e.key !== 'lastActive') { - return - } - lastActive = e.newValue - }) - - let intervalId = 0 - const logoutCheck = () => { - const timeout = Date.now() - config.session_lifetime * 1000 - if (lastActive < timeout) { - clearTimeout(intervalId) - console.info('Inactivity timout reached, logging out') - const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) - window.location = logoutUrl - } - } - intervalId = setInterval(logoutCheck, 1000) -} - -/** - * Calls the server periodically to ensure that session and CSRF - * token doesn't expire - */ -export const initSessionHeartBeat = () => { - loadConfig() - - registerAutoLogout() - - if (!keepSessionAlive()) { - console.info('session heartbeat disabled') - return - } - let interval = startPolling() - - window.addEventListener('online', async () => { - console.info('browser is online again, resuming heartbeat') - interval = startPolling() - try { - await poll() - console.info('session token successfully updated after resuming network') - - // Let apps know we're online and requests will have the new token - emit('networkOnline', { - success: true, - }) - } catch (e) { - console.error('could not update session token after resuming network', e) - - // Let apps know we're online but requests might have an outdated token - emit('networkOnline', { - success: false, - }) - } - }) - window.addEventListener('offline', () => { - console.info('browser is offline, stopping heartbeat') - - // Let apps know we're offline - emit('networkOffline', {}) - - clearInterval(interval) - console.info('session heartbeat polling stopped') - }) -} diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts new file mode 100644 index 00000000000..42a9bfccef7 --- /dev/null +++ b/core/src/session-heartbeat.ts @@ -0,0 +1,158 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' +import { + fetchRequestToken, + getRequestToken, +} from './OC/requesttoken.ts' +import logger from './logger.js' + +interface OcJsConfig { + auto_logout: boolean + session_keepalive: boolean + session_lifetime: number +} + +// This is always set, exception would be e.g. error pages where this is undefined +const { + auto_logout: autoLogout, + session_keepalive: keepSessionAlive, + session_lifetime: sessionLifetime, +} = loadState<Partial<OcJsConfig>>('core', 'config', {}) + +/** + * Calls the server periodically to ensure that session and CSRF + * token doesn't expire + */ +export function initSessionHeartBeat() { + registerAutoLogout() + + if (!keepSessionAlive) { + logger.info('Session heartbeat disabled') + return + } + + let interval = startPolling() + window.addEventListener('online', async () => { + logger.info('Browser is online again, resuming heartbeat') + + interval = startPolling() + try { + await poll() + logger.info('Session token successfully updated after resuming network') + + // Let apps know we're online and requests will have the new token + emit('networkOnline', { + success: true, + }) + } catch (error) { + logger.error('could not update session token after resuming network', { error }) + + // Let apps know we're online but requests might have an outdated token + emit('networkOnline', { + success: false, + }) + } + }) + + window.addEventListener('offline', () => { + logger.info('Browser is offline, stopping heartbeat') + + // Let apps know we're offline + emit('networkOffline', {}) + + clearInterval(interval) + logger.info('Session heartbeat polling stopped') + }) +} + +/** + * Get interval in seconds + */ +function getInterval(): number { + const interval = sessionLifetime + ? Math.floor(sessionLifetime / 2) + : 900 + + // minimum one minute, max 24 hours, default 15 minutes + return Math.min( + 24 * 3600, + Math.max( + 60, + interval, + ), + ) +} + +/** + * Poll the CSRF token for changes. + * This will also extend the current session if needed. + */ +async function poll() { + try { + await fetchRequestToken() + } catch (error) { + logger.error('session heartbeat failed', { error }) + } +} + +/** + * Start an window interval with the polling as the callback. + * + * @return The interval id + */ +function startPolling(): number { + const interval = window.setInterval(poll, getInterval() * 1000) + + logger.info('session heartbeat polling started') + return interval +} + +/** + * If enabled this will register event listeners to track if a user is active. + * If not the user will be automatically logged out after the configured IDLE time. + */ +function registerAutoLogout() { + if (!autoLogout || !getCurrentUser()) { + return + } + + let lastActive = Date.now() + window.addEventListener('mousemove', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('touchstart', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('storage', (event) => { + if (event.key !== 'lastActive') { + return + } + if (event.newValue === null) { + return + } + lastActive = JSON.parse(event.newValue) + }) + + let intervalId = 0 + const logoutCheck = () => { + const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000 + if (lastActive < timeout) { + clearTimeout(intervalId) + logger.info('Inactivity timout reached, logging out') + const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) + window.location.href = logoutUrl + } + } + intervalId = window.setInterval(logoutCheck, 1000) +} diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js deleted file mode 100644 index 36833742d14..00000000000 --- a/core/src/tests/OC/requesttoken.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { beforeEach, describe, expect, test, vi } from 'vitest' -import { manageToken, setToken } from '../../OC/requesttoken.js' - -const eventbus = vi.hoisted(() => ({ emit: vi.fn() })) -vi.mock('@nextcloud/event-bus', () => eventbus) - -describe('request token', () => { - - let emit - let manager - const token = 'abc123' - - beforeEach(() => { - emit = vi.fn() - const head = window.document.getElementsByTagName('head')[0] - head.setAttribute('data-requesttoken', token) - - manager = manageToken(window.document, emit) - }) - - test('reads the token from the document', () => { - expect(manager.getToken()).toBe('abc123') - }) - - test('remembers the updated token', () => { - manager.setToken('bca321') - - expect(manager.getToken()).toBe('bca321') - }) - - describe('@nextcloud/auth integration', () => { - test('fires off an event for @nextcloud/auth', () => { - setToken('123') - - expect(eventbus.emit).toHaveBeenCalledWith('csrf-token-update', { token: '123' }) - }) - }) - -}) diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts new file mode 100644 index 00000000000..8f92dbed153 --- /dev/null +++ b/core/src/tests/OC/requesttoken.spec.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts' + +const eventbus = vi.hoisted(() => ({ emit: vi.fn() })) +vi.mock('@nextcloud/event-bus', () => eventbus) + +const server = setupServer() + +describe('getRequestToken', () => { + it('can read the token from DOM', () => { + mockToken('tokenmock-123') + expect(getRequestToken()).toBe('tokenmock-123') + }) + + it('can handle missing token', () => { + mockToken(undefined) + expect(getRequestToken()).toBeUndefined() + }) +}) + +describe('setRequestToken', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('does emit an event on change', () => { + setRequestToken('new-token') + expect(eventbus.emit).toBeCalledTimes(1) + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('does set the new token to the DOM', () => { + setRequestToken('new-token') + expect(document.head.dataset.requesttoken).toBe('new-token') + }) + + it('does remember the new token', () => { + mockToken('old-token') + setRequestToken('new-token') + expect(getRequestToken()).toBe('new-token') + }) + + it('throws if the token is not a string', () => { + // @ts-expect-error mocking + expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given') + }) + + it('throws if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + }) + + it('does not emit an event if the token is not valid', () => { + expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given') + expect(eventbus.emit).not.toBeCalled() + }) +}) + +describe('fetchRequestToken', () => { + const successfullCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json({ token: 'new-token' }) + }) + const forbiddenCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 403 }) + }) + const serverErrorCsrf = http.get('/index.php/csrftoken', () => { + return HttpResponse.json([], { status: 500 }) + }) + const networkErrorCsrf = http.get('/index.php/csrftoken', () => { + return new HttpResponse(null, { type: 'error' }) + }) + + beforeAll(() => { + server.listen() + }) + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('correctly parses response', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + const token = await fetchRequestToken() + expect(token).toBe('new-token') + }) + + it('sets the token', async () => { + server.use(successfullCsrf) + + mockToken('oldToken') + await fetchRequestToken() + expect(getRequestToken()).toBe('new-token') + }) + + it('does emit an event', async () => { + server.use(successfullCsrf) + + await fetchRequestToken() + expect(eventbus.emit).toHaveBeenCalledOnce() + expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' }) + }) + + it('handles 403 error due to invalid cookies', async () => { + server.use(forbiddenCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles server error', async () => { + server.use(serverErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API') + expect(getRequestToken()).toBe('oldToken') + }) + + it('handles network error', async () => { + server.use(networkErrorCsrf) + + mockToken('oldToken') + await expect(() => fetchRequestToken()).rejects.toThrow() + expect(getRequestToken()).toBe('oldToken') + }) +}) + +/** + * Mock the request token directly so we can test reading it. + * + * @param token - The CSRF token to mock + */ +function mockToken(token?: string) { + if (token === undefined) { + delete document.head.dataset.requesttoken + } else { + document.head.dataset.requesttoken = token + } +} diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts new file mode 100644 index 00000000000..61b82d92887 --- /dev/null +++ b/core/src/tests/OC/session-heartbeat.spec.ts @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const requestToken = vi.hoisted(() => ({ + fetchRequestToken: vi.fn<() => Promise<string>>(), + setRequestToken: vi.fn<(token: string) => void>(), +})) +vi.mock('../../OC/requesttoken.ts', () => requestToken) + +const initialState = vi.hoisted(() => ({ loadState: vi.fn() })) +vi.mock('@nextcloud/initial-state', () => initialState) + +describe('Session heartbeat', () => { + beforeAll(() => { + vi.useFakeTimers() + }) + + beforeEach(() => { + vi.clearAllTimers() + vi.resetModules() + vi.resetAllMocks() + }) + + it('sends heartbeat half the session lifetime when heartbeat enabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // reach past half, one call + await vi.advanceTimersByTimeAsync(60 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // almost there to the next, still one + await vi.advanceTimersByTimeAsync(135 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(1) + + // past it, second call + await vi.advanceTimersByTimeAsync(5 * 1000) + expect(requestToken.fetchRequestToken).toBeCalledTimes(2) + }) + + it('does not send heartbeat when heartbeat disabled', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: false, + session_lifetime: 300, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // less than half, still nothing + await vi.advanceTimersByTimeAsync(100 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // more than one, still nothing + await vi.advanceTimersByTimeAsync(300 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 55, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 30 / 55 seconds + await vi.advanceTimersByTimeAsync(30 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // 59 / 55 seconds should not be called except it does not limit + await vi.advanceTimersByTimeAsync(29 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // now one minute has passed + await vi.advanceTimersByTimeAsync(1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) + + it('limit heartbeat to at least one minute', async () => { + initialState.loadState.mockImplementationOnce(() => ({ + session_keepalive: true, + session_lifetime: 50 * 60 * 60, + })) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + // initial state loaded + expect(initialState.loadState).toBeCalledWith('core', 'config', {}) + + // 23 hours + await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000) + expect(requestToken.fetchRequestToken).not.toBeCalled() + + // one day - it should be called now + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() + }) +}) diff --git a/core/src/twofactor-request-token.ts b/core/src/twofactor-request-token.ts new file mode 100644 index 00000000000..868ceec01e9 --- /dev/null +++ b/core/src/twofactor-request-token.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { onRequestTokenUpdate } from '@nextcloud/auth' +import { getBaseUrl } from '@nextcloud/router' + +document.addEventListener('DOMContentLoaded', () => { + onRequestTokenUpdate((token) => { + const cancelLink = window.document.getElementById('cancel-login') + if (!cancelLink) { + return + } + + const href = cancelLink.getAttribute('href') + if (!href) { + return + } + + const parsedHref = new URL(href, getBaseUrl()) + parsedHref.searchParams.set('requesttoken', token) + cancelLink.setAttribute('href', parsedHref.pathname + parsedHref.search) + }) +}) diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue index d1b4694ebc1..5b7ead636bd 100644 --- a/core/src/views/AccountMenu.vue +++ b/core/src/views/AccountMenu.vue @@ -197,27 +197,15 @@ export default defineComponent({ } .account-menu { - :deep(button) { - // Normally header menus are slightly translucent when not active - // this is generally ok but for the avatar this is weird so fix the opacity - opacity: 1 !important; - - // The avatar is just the "icon" of the button - // So we add the focus-visible manually - &:focus-visible { - .account-menu__avatar { - border: var(--border-width-input-focused) solid var(--color-background-plain-text); - } - } - } - - // Ensure we do not wast space, as the header menu sets a default width of 350px - :deep(.header-menu__content) { - width: fit-content !important; - } - &__avatar { + --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%); + outline: var(--account-menu-outline); + position: fixed; + // do not apply the alpha mask on the avatar div + mask: none !important; + &:hover { + --account-menu-outline: none; // Add hover styles similar to the focus-visible style border: var(--border-width-input-focused) solid var(--color-background-plain-text); } @@ -235,5 +223,25 @@ export default defineComponent({ flex: 0 1; } } + + // Ensure we do not waste space, as the header menu sets a default width of 350px + :deep(.header-menu__content) { + width: fit-content !important; + } + + :deep(button) { + // Normally header menus are slightly translucent when not active + // this is generally ok but for the avatar this is weird so fix the opacity + opacity: 1 !important; + + // The avatar is just the "icon" of the button + // So we add the focus-visible manually + &:focus-visible { + .account-menu__avatar { + --account-menu-outline: none; + border: var(--border-width-input-focused) solid var(--color-background-plain-text); + } + } + } } </style> diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue index 292e2bbcd29..924ddcea56b 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -9,7 +9,7 @@ :aria-label="t('core', 'Search contacts')" @open="handleOpen"> <template #trigger> - <Contacts class="contactsmenu__trigger-icon" :size="20" /> + <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" /> </template> <div class="contactsmenu__menu"> <div class="contactsmenu__menu__input-wrapper"> @@ -27,7 +27,7 @@ </div> <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')"> <template #icon> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <NcEmptyContent v-else-if="loadingText" :name="loadingText"> @@ -37,7 +37,7 @@ </NcEmptyContent> <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')"> <template #icon> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <div v-else class="contactsmenu__menu__content"> @@ -62,39 +62,46 @@ </template> <script> +import { mdiContacts, mdiMagnify } from '@mdi/js' +import { generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import Contacts from 'vue-material-design-icons/Contacts.vue' import debounce from 'debounce' -import { getCurrentUser } from '@nextcloud/auth' -import { generateUrl } from '@nextcloud/router' -import Magnify from 'vue-material-design-icons/Magnify.vue' + import NcButton from '@nextcloud/vue/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 { translate as t } from '@nextcloud/l10n' +import NcTextField from '@nextcloud/vue/components/NcTextField' import Contact from '../components/ContactsMenu/Contact.vue' import logger from '../logger.js' import Nextcloud from '../mixins/Nextcloud.js' -import NcTextField from '@nextcloud/vue/components/NcTextField' export default { name: 'ContactsMenu', components: { Contact, - Contacts, - Magnify, NcButton, NcEmptyContent, NcHeaderMenu, + NcIconSvgWrapper, NcLoadingIcon, NcTextField, }, mixins: [Nextcloud], + setup() { + return { + mdiContacts, + mdiMagnify, + } + }, + data() { const user = getCurrentUser() return { diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index 9236d1a9d09..a6fe8442779 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -95,6 +95,8 @@ <script> import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' + import queryString from 'query-string' import LoginForm from '../components/login/LoginForm.vue' @@ -152,8 +154,7 @@ export default { methods: { passwordResetFinished() { - this.resetPasswordTarget = '' - this.directLogin = true + window.location.href = generateUrl('login') }, }, } 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> |